mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
425f27f989 | ||
|
|
7d56a5a4d6 | ||
|
|
de9d886aba | ||
|
|
2e26bb9387 | ||
|
|
6138487888 | ||
|
|
e3a5b915a6 | ||
|
|
84849a24ca | ||
|
|
91675bde4f | ||
|
|
804dbb678b | ||
|
|
4dc35e9bb0 | ||
|
|
2ba7ee9ce5 | ||
|
|
4530504a3d | ||
|
|
2ad0caf069 | ||
|
|
23fe2c16b3 | ||
|
|
40d2fac29c | ||
|
|
f77f40b987 | ||
|
|
9a7d93909d | ||
|
|
1086d1d183 | ||
|
|
ec6c5ad793 | ||
|
|
f126f91a7c | ||
|
|
c8c2219a87 | ||
|
|
3da3e98264 | ||
|
|
acff8f38dd | ||
|
|
f78fa27215 | ||
|
|
acc49196c1 | ||
|
|
6305e3e092 | ||
|
|
7b24d9160d | ||
|
|
de612c47f5 | ||
|
|
bb4fe48c9a | ||
|
|
b7efaae709 | ||
|
|
55398e21df | ||
|
|
11628c3166 | ||
|
|
69eabcd1fc | ||
|
|
8b69c2ea12 | ||
|
|
66dbef3e51 | ||
|
|
892c177e93 | ||
|
|
ba454552c5 | ||
|
|
e45e58108b | ||
|
|
6e4562e354 | ||
|
|
060d8fcadd | ||
|
|
d8b9409e96 | ||
|
|
b1c7c7c844 | ||
|
|
75ff4f486f | ||
|
|
7ba636d642 | ||
|
|
44aec2009f | ||
|
|
06c6ec052e | ||
|
|
b3ac4777ba | ||
|
|
0e2fcb1c7f |
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"name": "C# (.NET)",
|
||||
//"image": "mcr.microsoft.com/devcontainers/dotnet",
|
||||
// Workaround for https://github.com/devcontainers/images/issues/1752
|
||||
"build": {
|
||||
"dockerfile": "dotnet.Dockerfile"
|
||||
},
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
FROM mcr.microsoft.com/devcontainers/universal:latest
|
||||
|
||||
# Remove Yarn repository with expired GPG key to prevent apt-get update failures
|
||||
# Tracking issue: https://github.com/devcontainers/images/issues/1752
|
||||
RUN rm -f /etc/apt/sources.list.d/yarn.list
|
||||
@@ -0,0 +1,102 @@
|
||||
#
|
||||
# Dedicated .NET integration tests workflow, called from the manual integration test orchestrator.
|
||||
# Only runs integration test matrix entries (net10.0 and net472).
|
||||
#
|
||||
|
||||
name: dotnet-integration-tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
dotnet-integration-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release }
|
||||
- { targetFramework: "net472", os: "windows-latest", configuration: Release }
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
python
|
||||
workflow-samples
|
||||
|
||||
- name: Start Azure Cosmos DB Emulator
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Launching Azure Cosmos DB Emulator"
|
||||
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
|
||||
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.1.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Build dotnet solutions
|
||||
shell: bash
|
||||
run: |
|
||||
export SOLUTIONS=$(find ./dotnet/ -type f -name "*.slnx" | tr '\n' ' ')
|
||||
for solution in $SOLUTIONS; do
|
||||
dotnet build $solution -c ${{ matrix.configuration }} --warnaserror
|
||||
done
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Set up Durable Task and Azure Functions Integration Test Emulators
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: ./.github/actions/azure-functions-integration-setup
|
||||
|
||||
- name: Run Integration Tests
|
||||
shell: bash
|
||||
run: |
|
||||
export INTEGRATION_TEST_PROJECTS=$(find ./dotnet -type f -name "*IntegrationTests.csproj" | tr '\n' ' ')
|
||||
for project in $INTEGRATION_TEST_PROJECTS; do
|
||||
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')
|
||||
if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then
|
||||
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --filter "Category!=IntegrationDisabled"
|
||||
else
|
||||
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
|
||||
fi
|
||||
done
|
||||
env:
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
|
||||
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
|
||||
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AzureAI__Endpoint: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
AzureAI__DeploymentName: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
|
||||
AzureAI__BingConnectionId: ${{ vars.AZUREAI__BINGCONECTIONID }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MEDIA_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MEDIA_DEPLOYMENT_NAME }}
|
||||
FOUNDRY_MODEL_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MODEL_DEPLOYMENT_NAME }}
|
||||
FOUNDRY_CONNECTION_GROUNDING_TOOL: ${{ vars.FOUNDRY_CONNECTION_GROUNDING_TOOL }}
|
||||
@@ -0,0 +1,134 @@
|
||||
#
|
||||
# This workflow allows manually running integration tests against an open PR or a branch.
|
||||
# Go to Actions → "Integration Tests (Manual)" → Run workflow → enter a PR number or branch name.
|
||||
#
|
||||
# It calls dedicated integration-only workflows (dotnet-integration-tests and python-integration-tests),
|
||||
# passing a ref so they check out and test the correct code.
|
||||
# Changed paths are detected here so only the relevant test suites run.
|
||||
#
|
||||
|
||||
name: Integration Tests (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr-number:
|
||||
description: "PR number to run integration tests against (leave empty if using branch)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
branch:
|
||||
description: "Branch name to run integration tests against (leave empty if using PR number)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
resolve-ref:
|
||||
name: Resolve ref
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
checkout-ref: ${{ steps.resolve.outputs.checkout-ref }}
|
||||
dotnet-changes: ${{ steps.detect-changes.outputs.dotnet }}
|
||||
python-changes: ${{ steps.detect-changes.outputs.python }}
|
||||
steps:
|
||||
- name: Resolve checkout ref
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then
|
||||
echo "::error::Please provide either a PR number or a branch name, not both."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PR_NUMBER" ] && [ -z "$BRANCH" ]; then
|
||||
echo "::error::Please provide either a PR number or a branch name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
if ! echo "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then
|
||||
echo "::error::Invalid PR number. Only numeric values are allowed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state)
|
||||
PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
|
||||
|
||||
if [ "$PR_STATE" != "OPEN" ]; then
|
||||
echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT"
|
||||
echo "Running integration tests for PR #$PR_NUMBER"
|
||||
else
|
||||
if ! echo "$BRANCH" | grep -Eq '^[a-zA-Z0-9_./-]+$'; then
|
||||
echo "::error::Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes are allowed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "checkout-ref=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "Running integration tests for branch $BRANCH"
|
||||
fi
|
||||
|
||||
- name: Detect changed paths
|
||||
id: detect-changes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --repo "$REPO" --name-only)
|
||||
else
|
||||
# For branches, compare against main using the GitHub API
|
||||
CHANGED_FILES=$(gh api "repos/$REPO/compare/main...$BRANCH" --jq '.files[].filename')
|
||||
fi
|
||||
|
||||
DOTNET_CHANGES=false
|
||||
PYTHON_CHANGES=false
|
||||
|
||||
if echo "$CHANGED_FILES" | grep -q '^dotnet/'; then
|
||||
DOTNET_CHANGES=true
|
||||
fi
|
||||
|
||||
if echo "$CHANGED_FILES" | grep -q '^python/'; then
|
||||
PYTHON_CHANGES=true
|
||||
fi
|
||||
|
||||
echo "dotnet=$DOTNET_CHANGES" >> "$GITHUB_OUTPUT"
|
||||
echo "python=$PYTHON_CHANGES" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected changes — dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
|
||||
|
||||
dotnet-integration-tests:
|
||||
name: .NET Integration Tests
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.dotnet-changes == 'true'
|
||||
uses: ./.github/workflows/dotnet-integration-tests.yml
|
||||
with:
|
||||
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
|
||||
secrets: inherit
|
||||
|
||||
python-integration-tests:
|
||||
name: Python Integration Tests
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.python-changes == 'true'
|
||||
uses: ./.github/workflows/python-integration-tests.yml
|
||||
with:
|
||||
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,273 @@
|
||||
#
|
||||
# Dedicated Python integration tests workflow, called from the manual integration test orchestrator.
|
||||
# Runs all tests (unit + integration) split into parallel jobs by provider.
|
||||
#
|
||||
# NOTE: This workflow and python-merge-tests.yml share the same set of parallel
|
||||
# test jobs. Keep them in sync — when adding, removing, or modifying a job here,
|
||||
# apply the same change to python-merge-tests.yml.
|
||||
#
|
||||
|
||||
name: python-integration-tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
UV_PYTHON: "3.13"
|
||||
|
||||
jobs:
|
||||
# Unit tests: all non-integration tests across all packages
|
||||
python-tests-unit:
|
||||
name: Python Integration Tests - Unit
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (unit tests only)
|
||||
run: >
|
||||
uv run poe all-tests
|
||||
-m "not integration"
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
# OpenAI integration tests
|
||||
python-tests-openai:
|
||||
name: Python Integration Tests - OpenAI
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (OpenAI integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/core/tests/openai
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
# Azure OpenAI integration tests
|
||||
python-tests-azure-openai:
|
||||
name: Python Integration Tests - Azure OpenAI
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest (Azure OpenAI integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/core/tests/azure
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
# Misc integration tests (Anthropic, Ollama, MCP)
|
||||
python-tests-misc-integration:
|
||||
name: Python Integration Tests - Misc
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (Anthropic, Ollama, MCP integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/anthropic/tests
|
||||
packages/ollama/tests
|
||||
packages/core/tests/core/test_mcp.py
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
# Azure Functions + Durable Task integration tests
|
||||
python-tests-functions:
|
||||
name: Python Integration Tests - Functions
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
FUNCTIONS_WORKER_RUNTIME: "python"
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||
AzureWebJobsStorage: "UseDevelopmentStorage=true"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Set up Azure Functions Integration Test Emulators
|
||||
uses: ./.github/actions/azure-functions-integration-setup
|
||||
id: azure-functions-setup
|
||||
- name: Test with pytest (Functions + Durable Task integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/azurefunctions/tests/integration_tests
|
||||
packages/durabletask/tests/integration_tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
# Azure AI integration tests
|
||||
python-tests-azure-ai:
|
||||
name: Python Integration Tests - Azure AI
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest
|
||||
timeout-minutes: 15
|
||||
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
[
|
||||
python-tests-unit,
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-azure-ai
|
||||
]
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Failed!')
|
||||
|
||||
- name: Fail workflow if tests cancelled
|
||||
if: contains(join(needs.*.result, ','), 'cancelled')
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
@@ -1,4 +1,9 @@
|
||||
name: Python - Merge - Tests
|
||||
#
|
||||
# NOTE: This workflow and python-integration-tests.yml share the same set of
|
||||
# parallel test jobs. Keep them in sync — when adding, removing, or modifying a
|
||||
# job here, apply the same change to python-integration-tests.yml.
|
||||
#
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -10,13 +15,13 @@ on:
|
||||
- cron: "0 0 * * *" # Run at midnight UTC daily
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
RUN_INTEGRATION_TESTS: "true"
|
||||
UV_PYTHON: "3.13"
|
||||
RUN_SAMPLES_TESTS: ${{ vars.RUN_SAMPLES_TESTS }}
|
||||
|
||||
jobs:
|
||||
@@ -26,7 +31,13 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
pythonChanges: ${{ steps.filter.outputs.python }}
|
||||
coreChanged: ${{ steps.filter.outputs.core }}
|
||||
openaiChanged: ${{ steps.filter.outputs.openai }}
|
||||
azureChanged: ${{ steps.filter.outputs.azure }}
|
||||
miscChanged: ${{ steps.filter.outputs.misc }}
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
azureAiChanged: ${{ steps.filter.outputs.azure-ai }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -35,6 +46,27 @@ jobs:
|
||||
filters: |
|
||||
python:
|
||||
- 'python/**'
|
||||
core:
|
||||
- 'python/packages/core/agent_framework/_*.py'
|
||||
- 'python/packages/core/agent_framework/_workflows/**'
|
||||
- 'python/packages/core/agent_framework/exceptions.py'
|
||||
- 'python/packages/core/agent_framework/observability.py'
|
||||
openai:
|
||||
- 'python/packages/core/agent_framework/openai/**'
|
||||
- 'python/packages/core/tests/openai/**'
|
||||
azure:
|
||||
- 'python/packages/core/agent_framework/azure/**'
|
||||
- 'python/packages/core/tests/azure/**'
|
||||
misc:
|
||||
- 'python/packages/anthropic/**'
|
||||
- 'python/packages/ollama/**'
|
||||
- 'python/packages/core/agent_framework/_mcp.py'
|
||||
- 'python/packages/core/tests/core/test_mcp.py'
|
||||
functions:
|
||||
- 'python/packages/azurefunctions/**'
|
||||
- 'python/packages/durabletask/**'
|
||||
azure-ai:
|
||||
- 'python/packages/azure-ai/**'
|
||||
# run only if 'python' files were changed
|
||||
- name: python tests
|
||||
if: steps.filter.outputs.python == 'true'
|
||||
@@ -43,34 +75,15 @@ jobs:
|
||||
- name: not python tests
|
||||
if: steps.filter.outputs.python != 'true'
|
||||
run: echo "NOT python file"
|
||||
python-tests-core:
|
||||
name: Python Tests - Core
|
||||
# Unit tests: always run all non-integration tests across all packages
|
||||
python-tests-unit:
|
||||
name: Python Tests - Unit
|
||||
needs: paths-filter
|
||||
if: github.event_name != 'pull_request' && needs.paths-filter.outputs.pythonChanges == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
os: [ubuntu-latest]
|
||||
environment: ["integration"]
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
# For Azure Functions integration tests
|
||||
FUNCTIONS_WORKER_RUNTIME: "python"
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||
AzureWebJobsStorage: "UseDevelopmentStorage=true"
|
||||
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
@@ -80,11 +93,219 @@ jobs:
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (unit tests only)
|
||||
run: >
|
||||
uv run poe all-tests
|
||||
-m "not integration"
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Unit test results
|
||||
|
||||
# OpenAI integration tests
|
||||
python-tests-openai:
|
||||
name: Python Tests - OpenAI Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.openaiChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (OpenAI integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/core/tests/openai
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Test OpenAI samples
|
||||
timeout-minutes: 10
|
||||
if: env.RUN_SAMPLES_TESTS == 'true'
|
||||
run: uv run pytest tests/samples/ -m "openai"
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: OpenAI integration test results
|
||||
|
||||
# Azure OpenAI integration tests
|
||||
python-tests-azure-openai:
|
||||
name: Python Tests - Azure OpenAI Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.azureChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest (Azure OpenAI integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/core/tests/azure
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Test Azure samples
|
||||
timeout-minutes: 10
|
||||
if: env.RUN_SAMPLES_TESTS == 'true'
|
||||
run: uv run pytest tests/samples/ -m "azure"
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Azure OpenAI integration test results
|
||||
|
||||
# Misc integration tests (Anthropic, Ollama, MCP)
|
||||
python-tests-misc-integration:
|
||||
name: Python Tests - Misc Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.miscChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (Anthropic, Ollama, MCP integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/anthropic/tests
|
||||
packages/ollama/tests
|
||||
packages/core/tests/core/test_mcp.py
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Misc integration test results
|
||||
|
||||
# Azure Functions + Durable Task integration tests
|
||||
python-tests-functions:
|
||||
name: Python Tests - Functions Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.functionsChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
FUNCTIONS_WORKER_RUNTIME: "python"
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||
AzureWebJobsStorage: "UseDevelopmentStorage=true"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
@@ -95,13 +316,15 @@ jobs:
|
||||
- name: Set up Azure Functions Integration Test Emulators
|
||||
uses: ./.github/actions/azure-functions-integration-setup
|
||||
id: azure-functions-setup
|
||||
- name: Test with pytest
|
||||
run: uv run poe all-tests -n logical --dist loadfile --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Test core samples
|
||||
timeout-minutes: 10
|
||||
if: env.RUN_SAMPLES_TESTS == 'true'
|
||||
run: uv run pytest tests/samples/ -m "openai" -m "azure"
|
||||
- name: Test with pytest (Functions + Durable Task integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/azurefunctions/tests/integration_tests
|
||||
packages/durabletask/tests/integration_tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
@@ -111,22 +334,20 @@ jobs:
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Test results
|
||||
title: Functions integration test results
|
||||
|
||||
python-tests-azure-ai:
|
||||
name: Python Tests - Azure AI
|
||||
needs: paths-filter
|
||||
if: github.event_name != 'pull_request' && needs.paths-filter.outputs.pythonChanges == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
os: [ubuntu-latest]
|
||||
environment: ["integration"]
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.azureAiChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
@@ -139,11 +360,8 @@ jobs:
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
@@ -153,7 +371,7 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest
|
||||
timeout-minutes: 15
|
||||
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist loadfile --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Test Azure AI samples
|
||||
timeout-minutes: 10
|
||||
@@ -177,11 +395,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
[
|
||||
python-tests-core,
|
||||
python-tests-azure-ai
|
||||
python-tests-unit,
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-azure-ai,
|
||||
]
|
||||
steps:
|
||||
|
||||
- name: Fail workflow if tests failed
|
||||
id: check_tests_failed
|
||||
if: contains(join(needs.*.result, ','), 'failure')
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
name: Python - Sample Validation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # Run at midnight UTC daily
|
||||
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
jobs:
|
||||
validate-01-get-started:
|
||||
name: Validate 01-get-started
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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 }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: "3.12"
|
||||
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
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-01-get-started
|
||||
path: python/samples/_sample_validation/reports/
|
||||
|
||||
validate-02-agents:
|
||||
name: Validate 02-agents
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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 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 }}
|
||||
# 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 }}
|
||||
# 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
|
||||
with:
|
||||
python-version: "3.12"
|
||||
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
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents
|
||||
path: python/samples/_sample_validation/reports/
|
||||
|
||||
validate-03-workflows:
|
||||
name: Validate 03-workflows
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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 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 }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: "3.12"
|
||||
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
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-03-workflows
|
||||
path: python/samples/_sample_validation/reports/
|
||||
|
||||
validate-04-hosting:
|
||||
name: Validate 04-hosting
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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 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 }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: "3.12"
|
||||
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
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-04-hosting
|
||||
path: python/samples/_sample_validation/reports/
|
||||
|
||||
validate-05-end-to-end:
|
||||
name: Validate 05-end-to-end
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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 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 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 }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: "3.12"
|
||||
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
|
||||
|
||||
- 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/
|
||||
|
||||
validate-autogen-migration:
|
||||
name: Validate autogen-migration
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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 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 }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: "3.12"
|
||||
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
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-autogen-migration
|
||||
path: python/samples/_sample_validation/reports/
|
||||
|
||||
validate-semantic-kernel-migration:
|
||||
name: Validate semantic-kernel-migration
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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 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 }}
|
||||
# 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 }}
|
||||
# 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
|
||||
with:
|
||||
python-version: "3.12"
|
||||
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
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-semantic-kernel-migration
|
||||
path: python/samples/_sample_validation/reports/
|
||||
@@ -1114,6 +1114,7 @@ Defaults introduced by this change:
|
||||
- `RedisContextProvider.DEFAULT_SOURCE_ID = "redis"`
|
||||
- `RedisHistoryProvider.DEFAULT_SOURCE_ID = "redis_memory"`
|
||||
- `AzureAISearchContextProvider.DEFAULT_SOURCE_ID = "azure_ai_search"`
|
||||
- `FoundryMemoryProvider.DEFAULT_SOURCE_ID = "foundry_memory"`
|
||||
|
||||
|
||||
## Comparison to .NET Implementation
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: westey-m
|
||||
date: 2026-02-24
|
||||
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub, lokitoth, alliscode, taochenosu, moonbox3
|
||||
consulted:
|
||||
informed:
|
||||
---
|
||||
|
||||
# AdditionalProperties for AIAgent and AgentSession
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The `AIAgent` base class currently exposes `Id`, `Name`, and `Description` as its core metadata properties, and `AgentSession` exposes only a `StateBag` property.
|
||||
Neither type has a mechanism for attaching arbitrary metadata, such as protocol-specific descriptors (e.g., A2A agent cards), hosting attributes, session-level tags, or custom user-defined metadata for discovery and routing.
|
||||
|
||||
Other types in the framework already carry `AdditionalProperties` — notably `AgentRunOptions`, `AgentResponse`, and `AgentResponseUpdate` — all using `AdditionalPropertiesDictionary` from `Microsoft.Extensions.AI`.
|
||||
Adding a similar property to `AIAgent` and `AgentSession` would give both types a consistent, extensible metadata surface.
|
||||
|
||||
Related: [Work Item #2133](https://github.com/microsoft/agent-framework/issues/2133)
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Consistency**: Other core types (`AgentRunOptions`, `AgentResponse`, `AgentResponseUpdate`) already expose `AdditionalProperties`. `AIAgent` and `AgentSession` are the major abstractions that lack this.
|
||||
- **Extensibility**: Hosting libraries, protocol adapters (A2A, AG-UI), and discovery mechanisms need a place to attach agent-level and session-level metadata without subclassing.
|
||||
- **Simplicity**: The solution should be easy to understand and use; avoid over-engineering.
|
||||
- **Minimal breaking change**: The addition should not require changes to existing agent implementations.
|
||||
- **Clear semantics**: Users should understand what `AdditionalProperties` on an agent or session means and how it differs from `AdditionalProperties` on `AgentRunOptions`.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Surface Area
|
||||
|
||||
- **Option A**: Public get-only property, auto-initialized (`AdditionalPropertiesDictionary AdditionalProperties { get; } = new()`) on both `AIAgent` and `AgentSession`
|
||||
- **Option B**: Public get/set nullable property (`AdditionalPropertiesDictionary? AdditionalProperties { get; set; }`) on both `AIAgent` and `AgentSession`
|
||||
- **Option C**: Constructor-injected dictionary with public get-only accessor on both `AIAgent` and `AgentSession`
|
||||
- **Option D**: External container/wrapper object — metadata lives outside `AIAgent` and `AgentSession`; no changes to the base classes
|
||||
|
||||
### Semantics
|
||||
|
||||
- **Option 1**: Metadata only — describes the agent or session; not propagated when calling `IChatClient`
|
||||
- **Option 2**: Passed down the stack — merged into `ChatOptions.AdditionalProperties` during `ChatClientAgent` runs
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
The chosen option is **Option D + Option 1**: an external container/wrapper object, used purely as metadata.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Good, because `AIAgent` and `AgentSession` remain unchanged, avoiding any increase to the core framework surface area while still enabling extensible metadata.
|
||||
- Good, because an external wrapper (owned by hosting/protocol libraries or user code, not the `AIAgent` / `AgentSession` base classes) can internally use `AdditionalPropertiesDictionary` to stay consistent with existing patterns on `AgentRunOptions`, `AgentResponse`, and `AgentResponseUpdate`.
|
||||
- Good, because metadata-only semantics keep a clean separation from per-run extensibility (`AgentRunOptions.AdditionalProperties`) and avoid unexpected side effects during agent execution.
|
||||
- Good, because no additional allocation occurs on `AIAgent` or `AgentSession` when no metadata is needed; external wrappers can be created only when metadata is required.
|
||||
- Bad, because callers and libraries must manage and pass around both the agent/session instance and its associated metadata wrapper, keeping them correctly associated.
|
||||
- Bad, because different hosting or protocol layers may define their own wrapper types, which can fragment the ecosystem unless conventions are agreed upon.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Option A — Public get-only property, auto-initialized
|
||||
|
||||
The property is always non-null and ready to use. Users add metadata after construction.
|
||||
|
||||
```csharp
|
||||
public abstract partial class AIAgent
|
||||
{
|
||||
public AdditionalPropertiesDictionary AdditionalProperties { get; } = new();
|
||||
}
|
||||
|
||||
public abstract partial class AgentSession
|
||||
{
|
||||
public AdditionalPropertiesDictionary AdditionalProperties { get; } = new();
|
||||
}
|
||||
|
||||
// Usage
|
||||
agent.AdditionalProperties["protocol"] = "A2A";
|
||||
agent.AdditionalProperties.Add<MyAgentCardInfo>(cardInfo);
|
||||
session.AdditionalProperties["tenant"] = tenantId;
|
||||
```
|
||||
|
||||
- Good, because users never encounter `null` — no defensive null checks needed.
|
||||
- Good, because the dictionary reference cannot be replaced, preventing accidental data loss.
|
||||
- Good, because it is the simplest API surface to use.
|
||||
- Neutral, because it always allocates, even when no metadata is needed. The allocation cost is negligible.
|
||||
- Bad, because it cannot be set at construction time as a single object (users must populate it post-construction).
|
||||
|
||||
### Option B — Public get/set nullable property
|
||||
|
||||
Matches the existing pattern on `AgentRunOptions`, `AgentResponse`, and `AgentResponseUpdate`.
|
||||
|
||||
```csharp
|
||||
public abstract partial class AIAgent
|
||||
{
|
||||
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
|
||||
}
|
||||
|
||||
public abstract partial class AgentSession
|
||||
{
|
||||
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
|
||||
}
|
||||
|
||||
// Usage
|
||||
agent.AdditionalProperties ??= new();
|
||||
agent.AdditionalProperties["protocol"] = "A2A";
|
||||
session.AdditionalProperties ??= new();
|
||||
session.AdditionalProperties["tenant"] = tenantId;
|
||||
```
|
||||
|
||||
- Good, because it is consistent with the existing `AdditionalProperties` pattern on `AgentRunOptions` and `AgentResponse`.
|
||||
- Good, because it avoids allocation when no metadata is needed.
|
||||
- Bad, because every consumer must null-check before reading or writing.
|
||||
- Bad, because the entire dictionary can be replaced, risking accidental loss of metadata set by other components (e.g., a hosting library sets metadata, then user code replaces the dictionary).
|
||||
|
||||
### Option C — Constructor-injected with public get
|
||||
|
||||
The dictionary is provided at construction time and exposed as get-only.
|
||||
|
||||
```csharp
|
||||
public abstract partial class AIAgent
|
||||
{
|
||||
public AdditionalPropertiesDictionary AdditionalProperties { get; }
|
||||
|
||||
protected AIAgent(AdditionalPropertiesDictionary? additionalProperties = null)
|
||||
{
|
||||
this.AdditionalProperties = additionalProperties ?? new();
|
||||
}
|
||||
}
|
||||
|
||||
public abstract partial class AgentSession
|
||||
{
|
||||
public AdditionalPropertiesDictionary AdditionalProperties { get; }
|
||||
|
||||
protected AgentSession(AdditionalPropertiesDictionary? additionalProperties = null)
|
||||
{
|
||||
this.AdditionalProperties = additionalProperties ?? new();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Good, because an agent's metadata can be established before any code runs against it.
|
||||
- Bad, because `AdditionalPropertiesDictionary` has no read-only variant, so the constructor-injection pattern gives a false sense of immutability — callers can still mutate the dictionary contents after construction.
|
||||
- Bad, because it requires adding a constructor parameter to the abstract base classes, which is a source-breaking change for all existing `AIAgent` and `AgentSession` subclasses (even with a default value, it changes the constructor signature that derived classes chain to).
|
||||
- Bad, because it is more complex with little practical benefit over Option A, since post-construction mutation is equally possible.
|
||||
|
||||
### Option D — External container/wrapper object
|
||||
|
||||
Rather than adding `AdditionalProperties` to `AIAgent` or `AgentSession`, users wrap the agent or session in a container object that carries both the instance and any associated metadata. No changes to the base classes are required.
|
||||
|
||||
```csharp
|
||||
public class AgentWithMetadata
|
||||
{
|
||||
public required AIAgent Agent { get; init; }
|
||||
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
|
||||
}
|
||||
|
||||
public class SessionWithMetadata
|
||||
{
|
||||
public required AgentSession Session { get; init; }
|
||||
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
|
||||
}
|
||||
|
||||
// Usage
|
||||
var wrapper = new AgentWithMetadata
|
||||
{
|
||||
Agent = myAgent,
|
||||
AdditionalProperties = new() { ["protocol"] = "A2A" }
|
||||
};
|
||||
```
|
||||
|
||||
- Good, because it requires no changes to `AIAgent` or `AgentSession`, avoiding any risk of breaking existing implementations.
|
||||
- Good, because metadata is clearly external to the agent and session, eliminating any ambiguity about whether it might be passed down the execution stack.
|
||||
- Good, because the container pattern gives the user full control over the metadata lifecycle and serialization.
|
||||
- Bad, because it is not discoverable — users must know about the container convention; there is no built-in API surface guiding them.
|
||||
|
||||
### Option 1 — Metadata only
|
||||
|
||||
`AdditionalProperties` on `AIAgent` and `AgentSession` is descriptive metadata. It is **not** automatically propagated when the agent calls downstream services such as `IChatClient`.
|
||||
|
||||
- Good, because it keeps a clean separation of concerns: agent/session-level metadata vs. per-run options.
|
||||
- Good, because it avoids unintended side effects — metadata added for discovery or hosting won't leak into LLM requests.
|
||||
- Good, because per-run extensibility is already served by `AgentRunOptions.AdditionalProperties` (see [ADR 0014](0014-feature-collections.md)), so there is no gap.
|
||||
- Neutral, because users who want to pass agent metadata to the chat client can still do so manually via `AgentRunOptions`.
|
||||
|
||||
### Option 2 — Passed down the stack
|
||||
|
||||
`AdditionalProperties` on `AIAgent` and `AgentSession` are automatically merged into `ChatOptions.AdditionalProperties` (or similar) when `ChatClientAgent` invokes the underlying `IChatClient`.
|
||||
|
||||
- Good, because it provides an automatic way to send agent-level configuration to the LLM provider.
|
||||
- Bad, because it conflates metadata (describing the agent) with operational parameters (controlling LLM behavior), leading to potential confusion.
|
||||
- Bad, because it risks leaking unrelated metadata into LLM calls (e.g., hosting tags, discovery URLs).
|
||||
- Bad, because it would be `ChatClientAgent`-specific behavior on a base-class property, creating inconsistency for non-`ChatClientAgent` implementations.
|
||||
- Bad, because it duplicates the purpose of `AgentRunOptions.AdditionalProperties`, which already serves as the per-run extensibility point for passing data down the stack.
|
||||
|
||||
## Serialization Considerations
|
||||
|
||||
`AIAgent` instances are not typically serialized, so `AdditionalProperties` on `AIAgent` does not raise serialization concerns.
|
||||
|
||||
`AgentSession` instances, however, are routinely serialized and deserialized — for example, to persist conversation state across application restarts. Adding `AdditionalProperties` to `AgentSession` introduces a serialization challenge: `AdditionalPropertiesDictionary` is a `Dictionary<string, object?>`, and `object?` values do not carry enough type information for the JSON deserializer to reconstruct the original CLR types.
|
||||
|
||||
### Default behavior — JsonElement round-tripping
|
||||
|
||||
By default, when an `AgentSession` with `AdditionalProperties` is serialized and later deserialized, any complex objects stored as values in the dictionary will be deserialized as `JsonElement` rather than their original types. This is the same behavior exhibited by `ChatMessage.AdditionalProperties` and other `AdditionalPropertiesDictionary` usages in `Microsoft.Extensions.AI`, and is the approach we will follow.
|
||||
|
||||
### Custom serialization via JsonSerializerOptions
|
||||
|
||||
`AIAgent.SerializeSessionAsync` and `AIAgent.DeserializeSessionAsync` already accept an optional `JsonSerializerOptions` parameter. Users who need strongly-typed round-tripping of `AdditionalProperties` values can supply custom options with appropriate converters or type info resolvers. This is non-trivial to implement but provides full control over deserialization behavior when needed.
|
||||
|
||||
## More Information
|
||||
|
||||
- [ADR 0014 — Feature Collections](0014-feature-collections.md) established that `AdditionalProperties` on `AgentRunOptions` serves as the per-run extensibility mechanism. The proposed agent-level and session-level properties serve a complementary, distinct purpose: static metadata describing the agent or session itself.
|
||||
- `AdditionalPropertiesDictionary` is defined in `Microsoft.Extensions.AI` and is already a dependency of `Microsoft.Agents.AI.Abstractions`. No new package references are needed.
|
||||
- Type-safe access is available via the existing `AdditionalPropertiesExtensions` helper methods (`Add<T>`, `TryGetValue<T>`, `Contains<T>`, `Remove<T>`), which use `typeof(T).FullName` as the dictionary key.
|
||||
@@ -0,0 +1,390 @@
|
||||
# Vector Stores and Embeddings
|
||||
|
||||
## Overview
|
||||
|
||||
This feature ports the vector store abstractions, embedding generator abstractions, and their implementations from Semantic Kernel into Agent Framework. The ported code follows AF's coding standards, feels native to AF, and is structured to allow data models/schemas to be reusable across both frameworks. The embedding abstraction combines the best of SK's `EmbeddingGeneratorBase` and MEAI's `IEmbeddingGenerator<TInput, TEmbedding>`.
|
||||
|
||||
| Capability | Description |
|
||||
| --- | --- |
|
||||
| Embedding generation | Generic embedding client abstraction supporting text, image, and audio inputs |
|
||||
| Vector store collections | CRUD operations on vector store collections (upsert, get, delete) |
|
||||
| Vector search | Unified search interface with `search_type` parameter (`"vector"`, `"keyword_hybrid"`) |
|
||||
| Data model decorator | `@vectorstoremodel` decorator for defining vector store data models (supports Pydantic, dataclasses, plain classes, dicts) |
|
||||
| Agent tools | `create_search_tool`, `create_upsert_tool`, `create_get_tool`, `create_delete_tool` for agent-usable vector store operations |
|
||||
| In-memory store | Zero-dependency vector store for testing and development |
|
||||
| 13+ connectors | Azure AI Search, Qdrant, Redis, PostgreSQL, MongoDB, Cosmos DB, Pinecone, Chroma, Weaviate, Oracle, SQL Server, FAISS |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Embedding Abstractions (combining SK + MEAI)
|
||||
- **Both Protocol and Base class** (matching AF's `SupportsChatGetResponse` + `BaseChatClient` pattern):
|
||||
- `SupportsGetEmbeddings` — Protocol for duck-typing
|
||||
- `BaseEmbeddingClient` — ABC base class for implementations (similar to `BaseChatClient`)
|
||||
- **Generic input type** (`EmbeddingInputT`, default `str`) from MEAI — allows image/audio embeddings in the future
|
||||
- **Generic output type** (`EmbeddingT`, default `list[float]`) from MEAI — supports `list[float]`, `list[int]`, `bytes`, etc.
|
||||
- **Generic order**: `[EmbeddingInputT, EmbeddingT, EmbeddingOptionsT]` — options last, matching MEAI's `IEmbeddingGenerator<TInput, TEmbedding>` with options appended
|
||||
- **TypeVar naming convention**: Use `SuffixT` per AF standard (e.g., `EmbeddingInputT`, `EmbeddingT`, `ModelT`, `KeyT`)
|
||||
- `EmbeddingGenerationOptions` TypedDict (inspired by MEAI, matching AF's `ChatOptions` pattern) — `total=False`, includes `dimensions`, `model_id`. No `additional_properties` since each implementation extends with its own fields.
|
||||
- Protocol and base class are generic over input, output, and options: `SupportsGetEmbeddings[EmbeddingInputT, EmbeddingT, OptionsContraT]`, `BaseEmbeddingClient[EmbeddingInputT, EmbeddingT, OptionsCoT]`
|
||||
- **`Embedding[EmbeddingT]` type** in `_types.py` — a lightweight generic class (not Pydantic) with `vector: EmbeddingT`, `model_id: str | None`, `dimensions: int | None` (explicit or computed from vector), `created_at: datetime | None`, `additional_properties: dict[str, Any]`
|
||||
- **`GeneratedEmbeddings[EmbeddingT, EmbeddingOptionsT]` type** — a list-like container of `Embedding[EmbeddingT]` objects with `options: EmbeddingOptionsT | None` (stores the options used to generate), `usage: dict[str, Any] | None`, `additional_properties: dict[str, Any]`
|
||||
- **No numpy dependency** — return `list[float]` by default; users cast as needed
|
||||
|
||||
### Vector Store Abstractions
|
||||
- **Port core abstractions without Pydantic for internal classes** — use plain classes
|
||||
- **Both Protocol and Base class** for vector store operations (matching AF pattern):
|
||||
- `SupportsVectorUpsert` / `SupportsVectorSearch` — Protocols for duck-typing (follows `Supports<Capability>` naming convention)
|
||||
- `BaseVectorCollection` / `BaseVectorSearch` — ABC base classes for implementations
|
||||
- `BaseVectorStore` — ABC base class for store operations (factory for collections, no protocol needed)
|
||||
- **TypeVar naming convention**: `ModelT`, `KeyT`, `FilterT` (suffix T, per AF standard)
|
||||
- **Support Pydantic for user-facing data models** — the `@vectorstoremodel` decorator and `VectorStoreCollectionDefinition` should work with Pydantic models, dataclasses, plain classes, and dicts
|
||||
- **Remove SK-specific dependencies** — no `KernelBaseModel`, `KernelFunction`, `KernelParameterMetadata`, `kernel_function`, `PromptExecutionSettings`
|
||||
- **Embedding types in `_types.py`**, embedding protocol/base class in `_clients.py`
|
||||
- **All vector store specific types, enums, protocols, base classes** in `_vectors.py`
|
||||
- **Error handling** uses AF's exception hierarchy (e.g., `IntegrationException` variants)
|
||||
|
||||
### Package Structure
|
||||
- **Embedding types** (`Embedding`, `GeneratedEmbeddings`, `EmbeddingGenerationOptions`) in `agent_framework/_types.py`
|
||||
- **Embedding protocol + base class** (`SupportsGetEmbeddings`, `BaseEmbeddingClient`) in `agent_framework/_clients.py`
|
||||
- **All vector store specific code** in a new `agent_framework/_vectors.py` module — this includes:
|
||||
- Enums: `FieldTypes`, `IndexKind`, `DistanceFunction`
|
||||
- `VectorStoreField`, `VectorStoreCollectionDefinition`
|
||||
- `SearchOptions`, `SearchResponse`, `RecordFilterOptions`
|
||||
- `@vectorstoremodel` decorator
|
||||
- Serialization/deserialization protocols
|
||||
- `VectorStoreRecordHandler`, `BaseVectorCollection`, `BaseVectorStore`, `BaseVectorSearch`
|
||||
- `SupportsVectorUpsert`, `SupportsVectorSearch` protocols
|
||||
- **OpenAI embeddings** in `agent_framework/openai/` (built into core, like OpenAI chat)
|
||||
- **Azure OpenAI embeddings** in `agent_framework/azure/` (built into core, follows `AzureOpenAIChatClient` pattern)
|
||||
- **Each vector store connector** in its own AF package under `packages/`
|
||||
- **In-memory store** in core (no external deps)
|
||||
- **TextSearch and its implementations** (Brave, Google) — last phase, separate work
|
||||
|
||||
## Naming: SK → AF
|
||||
|
||||
### Names that change
|
||||
|
||||
| SK Name | AF Name | Rationale |
|
||||
|---------|---------|-----------|
|
||||
| `VectorStoreCollection` | `BaseVectorCollection` | Drop redundant `Store`, add `Base` prefix per AF pattern |
|
||||
| `VectorStore` | `BaseVectorStore` | Add `Base` prefix per AF pattern |
|
||||
| `VectorSearch` | `BaseVectorSearch` | Add `Base` prefix per AF pattern |
|
||||
| `VectorSearchOptions` | `SearchOptions` | Shorter — context is already vector search |
|
||||
| `VectorSearchResult` | `SearchResponse` | Align with `ChatResponse`/`AgentResponse` |
|
||||
| `GetFilteredRecordOptions` | `RecordFilterOptions` | Shorter, more natural |
|
||||
| `EmbeddingGeneratorBase` | `BaseEmbeddingClient` | Matches AF `BaseChatClient` pattern |
|
||||
| `VectorStoreCollectionProtocol` | `SupportsVectorUpsert` | AF `Supports*` naming convention |
|
||||
| `VectorSearchProtocol` | `SupportsVectorSearch` | AF `Supports*` naming convention |
|
||||
| `__kernel_vectorstoremodel__` | `__vectorstoremodel__` | Drop SK `kernel` prefix |
|
||||
| `__kernel_vectorstoremodel_definition__` | `__vectorstoremodel_definition__` | Drop SK `kernel` prefix |
|
||||
| `search()` + `hybrid_search()` | `search(search_type=...)` | Single method with `Literal` parameter |
|
||||
| `SearchType` enum | `Literal["vector", "keyword_hybrid"]` | No enum, just a literal |
|
||||
| `KernelSearchResults` | `SearchResults` | Drop SK `Kernel` prefix (plural — container of `SearchResponse` items) |
|
||||
|
||||
### Names that stay the same
|
||||
|
||||
| Name | Location |
|
||||
|------|----------|
|
||||
| `@vectorstoremodel` | `_vectors.py` |
|
||||
| `VectorStoreField` | `_vectors.py` |
|
||||
| `VectorStoreCollectionDefinition` | `_vectors.py` |
|
||||
| `VectorStoreRecordHandler` | `_vectors.py` |
|
||||
| `FieldTypes` | `_vectors.py` |
|
||||
| `IndexKind` | `_vectors.py` |
|
||||
| `DistanceFunction` | `_vectors.py` |
|
||||
| `DISTANCE_FUNCTION_DIRECTION_HELPER` | `_vectors.py` |
|
||||
| `Embedding` | `_types.py` |
|
||||
| `GeneratedEmbeddings` | `_types.py` |
|
||||
| `EmbeddingGenerationOptions` | `_types.py` |
|
||||
| `SupportsGetEmbeddings` | `_clients.py` |
|
||||
|
||||
### New AF-only names (no SK equivalent)
|
||||
|
||||
| Name | Location | Purpose |
|
||||
|------|----------|---------|
|
||||
| `BaseEmbeddingClient` | `_clients.py` | ABC base for embedding implementations |
|
||||
| `EmbeddingInputT` | `_types.py` | TypeVar for generic embedding input (default `str`) |
|
||||
| `EmbeddingTelemetryLayer` | `observability.py` | MRO-based OTel tracing for embeddings |
|
||||
| `SupportsVectorUpsert` | `_vectors.py` | Protocol for collection CRUD |
|
||||
| `SupportsVectorSearch` | `_vectors.py` | Protocol for vector search |
|
||||
| `create_search_tool` | `_vectors.py` | Creates AF `FunctionTool` from vector search |
|
||||
|
||||
## Source Files Reference (SK → AF mapping)
|
||||
|
||||
### SK Source Files
|
||||
| SK File | Lines | Content |
|
||||
|---------|-------|---------|
|
||||
| `data/vector.py` | 2369 | All vector store abstractions, enums, decorator, search |
|
||||
| `data/_shared.py` | 184 | SearchOptions, KernelSearchResults, shared search types |
|
||||
| `data/text_search.py` | 349 | TextSearch base, TextSearchResult |
|
||||
| `connectors/ai/embedding_generator_base.py` | 50 | EmbeddingGeneratorBase ABC |
|
||||
| `connectors/in_memory.py` | 520 | InMemoryCollection, InMemoryStore |
|
||||
| `connectors/azure_ai_search.py` | 793 | Azure AI Search collection + store |
|
||||
| `connectors/azure_cosmos_db.py` | 1104 | Cosmos DB (Mongo + NoSQL) |
|
||||
| `connectors/redis.py` | 845 | Redis (Hashset + JSON) |
|
||||
| `connectors/qdrant.py` | 653 | Qdrant collection + store |
|
||||
| `connectors/postgres.py` | 987 | PostgreSQL collection + store |
|
||||
| `connectors/mongodb.py` | 633 | MongoDB Atlas collection + store |
|
||||
| `connectors/pinecone.py` | 691 | Pinecone collection + store |
|
||||
| `connectors/chroma.py` | 484 | Chroma collection + store |
|
||||
| `connectors/faiss.py` | 278 | FAISS (extends InMemory) |
|
||||
| `connectors/weaviate.py` | 804 | Weaviate collection + store |
|
||||
| `connectors/oracle.py` | 1267 | Oracle collection + store |
|
||||
| `connectors/sql_server.py` | 1132 | SQL Server collection + store |
|
||||
| `connectors/ai/open_ai/services/open_ai_text_embedding.py` | 91 | OpenAI embedding impl |
|
||||
| `connectors/ai/open_ai/services/open_ai_text_embedding_base.py` | 78 | OpenAI embedding base |
|
||||
| `connectors/brave.py` | ~200 | Brave TextSearch impl |
|
||||
| `connectors/google_search.py` | ~200 | Google TextSearch impl |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Embedding Abstractions & OpenAI Implementation âś… DONE
|
||||
**Goal:** Establish the embedding generator abstraction and ship one working implementation.
|
||||
**Mergeable:** Yes — adds new types/protocols, no breaking changes.
|
||||
**Status:** Merged via PR #4153. Closes sub-issue #4163.
|
||||
|
||||
#### 1.1 — Embedding types in `_types.py`
|
||||
- `EmbeddingInputT` TypeVar (default `str`) — generic input type for embedding generation
|
||||
- `EmbeddingT` TypeVar (default `list[float]`) — generic output embedding vector type
|
||||
- `Embedding[EmbeddingT]` generic class: `vector: EmbeddingT`, `model_id: str | None`, `dimensions: int | None` (explicit param or computed from vector length), `created_at: datetime | None`, `additional_properties: dict[str, Any]`
|
||||
- `GeneratedEmbeddings[EmbeddingT, EmbeddingOptionsT]` generic class: list-like container of `Embedding[EmbeddingT]` objects with `options: EmbeddingOptionsT | None` (the options used to generate), `usage: dict[str, Any] | None`, `additional_properties: dict[str, Any]`
|
||||
- `EmbeddingGenerationOptions` TypedDict (`total=False`): `dimensions: int`, `model_id: str` — follows the same pattern as `ChatOptions`. No `additional_properties` needed since it's a TypedDict and each implementation can extend with its own fields.
|
||||
|
||||
#### 1.2 — Embedding generator protocol + base class in `_clients.py`
|
||||
- `SupportsGetEmbeddings(Protocol[EmbeddingInputT, EmbeddingT, OptionsContraT])`: generic over input, output, and options (all with defaults), `get_embeddings(values: Sequence[EmbeddingInputT], *, options: OptionsContraT | None = None) -> Awaitable[GeneratedEmbeddings[EmbeddingT]]`
|
||||
- `BaseEmbeddingClient(ABC, Generic[EmbeddingInputT, EmbeddingT, OptionsCoT])`: ABC base class mirroring `BaseChatClient` pattern
|
||||
- `__init__` with `additional_properties`, etc.
|
||||
- Abstract `get_embeddings(...)` for subclasses to implement directly (no `_inner_*` indirection — simpler than chat, no middleware needed)
|
||||
- `EmbeddingTelemetryLayer` in `observability.py` — MRO-based telemetry (no closure), `gen_ai.operation.name = "embeddings"`
|
||||
|
||||
#### 1.3 — OpenAI embedding generator in `agent_framework/openai/` and `agent_framework/azure/`
|
||||
- `RawOpenAIEmbeddingClient` — implements `get_embeddings` via `_ensure_client()` factory
|
||||
- `OpenAIEmbeddingClient(OpenAIConfigMixin, EmbeddingTelemetryLayer[str, list[float], OptionsT], RawOpenAIEmbeddingClient[OptionsT])` — full client with config + telemetry layers
|
||||
- `OpenAIEmbeddingOptions(EmbeddingGenerationOptions)` — extends with `encoding_format`, `user`
|
||||
- `AzureOpenAIEmbeddingClient` in `agent_framework/azure/` — follows `AzureOpenAIChatClient` pattern with `AzureOpenAIConfigMixin`, `load_settings`, Entra ID credential support
|
||||
- `AzureOpenAISettings` extended with `embedding_deployment_name` (env var: `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`)
|
||||
|
||||
#### 1.4 — Tests and samples
|
||||
- Unit tests for types, protocol, base class, OpenAI client, Azure OpenAI client
|
||||
- Integration tests for OpenAI and Azure OpenAI (gated behind credentials check, `@pytest.mark.flaky`)
|
||||
- Samples in `samples/02-agents/embeddings/` — `openai_embeddings.py`, `azure_openai_embeddings.py`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Embedding Generators for Existing Providers
|
||||
**Goal:** Add embedding generators to all existing AF provider packages that have chat clients.
|
||||
**Mergeable:** Yes — each is independent, added to existing provider packages.
|
||||
|
||||
#### 2.1 — Azure AI Inference embedding (in `packages/azure-ai/`)
|
||||
#### 2.2 — Ollama embedding (in `packages/ollama/`)
|
||||
#### 2.3 — Anthropic embedding (in `packages/anthropic/`)
|
||||
#### 2.4 — Bedrock embedding (in `packages/bedrock/`)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Core Vector Store Abstractions
|
||||
**Goal:** Establish all vector store types, enums, the decorator, collection definition, and base classes.
|
||||
**Mergeable:** Yes — adds new abstractions, no breaking changes.
|
||||
|
||||
#### 3.1 — Vector store enums and field types in `_vectors.py`
|
||||
- `FieldTypes` enum: `KEY`, `VECTOR`, `DATA`
|
||||
- `IndexKind` enum: `HNSW`, `FLAT`, `IVF_FLAT`, `DISK_ANN`, `QUANTIZED_FLAT`, `DYNAMIC`, `DEFAULT`
|
||||
- `DistanceFunction` enum: `COSINE_SIMILARITY`, `COSINE_DISTANCE`, `DOT_PROD`, `EUCLIDEAN_DISTANCE`, `EUCLIDEAN_SQUARED_DISTANCE`, `MANHATTAN`, `HAMMING`, `DEFAULT`
|
||||
- No `SearchType` enum — use `Literal["vector", "keyword_hybrid"]` instead, per AF convention of avoiding unnecessary imports
|
||||
- `VectorStoreField` plain class (not Pydantic)
|
||||
- `VectorStoreCollectionDefinition` class (not Pydantic internally, but supports Pydantic models as input)
|
||||
- `SearchOptions` plain class — includes `score_threshold: float | None` for filtering results by score (see note below)
|
||||
- `SearchResponse` generic class
|
||||
- `RecordFilterOptions` plain class
|
||||
- `DISTANCE_FUNCTION_DIRECTION_HELPER` dict
|
||||
|
||||
#### 3.2 — `@vectorstoremodel` decorator
|
||||
- Port from SK, works with dataclasses, Pydantic models, plain classes, and dicts
|
||||
- Sets `__vectorstoremodel__` and `__vectorstoremodel_definition__` on the class
|
||||
- Remove SK-specific `kernel` prefix (`__kernel_vectorstoremodel__` → `__vectorstoremodel__`)
|
||||
|
||||
#### 3.3 — Serialization/deserialization protocols
|
||||
- `SerializeMethodProtocol`, `ToDictFunctionProtocol`, `FromDictFunctionProtocol`, etc.
|
||||
- Port the record handler logic but without Pydantic base class — use plain class or ABC
|
||||
|
||||
#### 3.4 — Vector store base classes in `_vectors.py`
|
||||
- `VectorStoreRecordHandler` — internal base class that handles serialization/deserialization between user data models and store-specific formats, plus embedding generation for vector fields. Both `BaseVectorCollection` and `BaseVectorSearch` extend this.
|
||||
- `BaseVectorCollection(VectorStoreRecordHandler)` — base for collections
|
||||
- Uses `SupportsGetEmbeddings` instead of `EmbeddingGeneratorBase`
|
||||
- Not a Pydantic model — use `__init__` with explicit params
|
||||
- `upsert`, `get`, `delete`, `ensure_collection_exists`, `collection_exists`, `ensure_collection_deleted`
|
||||
- Async context manager support
|
||||
- `BaseVectorStore` — base for stores
|
||||
- `get_collection`, `list_collection_names`, `collection_exists`, `ensure_collection_deleted`
|
||||
- Async context manager support
|
||||
|
||||
#### 3.5 — Vector search base class
|
||||
- `BaseVectorSearch(VectorStoreRecordHandler)` — base for vector search
|
||||
- Single `search(search_type=...)` method with `search_type: Literal["vector", "keyword_hybrid"]` parameter — no enum, just a literal
|
||||
- `_inner_search` abstract method for implementations
|
||||
- Filter building with lambda parser (AST-based)
|
||||
- Vector generation from values using embedding generator
|
||||
|
||||
#### 3.6 — Protocols for type checking
|
||||
- `SupportsVectorUpsert` — Protocol for upsert/get/delete operations
|
||||
- `SupportsVectorSearch` — Protocol for vector search (single `search()` with `search_type` parameter)
|
||||
- No separate `SupportsVectorHybridSearch` — search type is a parameter, not a separate capability
|
||||
- No protocol for `VectorStore` — it's a factory for collections, not a capability to duck-type against
|
||||
|
||||
#### 3.7 — Exception types
|
||||
- Add vector store exceptions under `IntegrationException` or create new branch
|
||||
- `VectorStoreException`, `VectorStoreOperationException`, `VectorSearchException`, `VectorStoreModelException`, etc.
|
||||
|
||||
#### 3.8 — `create_search_tool` on `BaseVectorSearch`
|
||||
- Method on `BaseVectorSearch` that creates an AF `FunctionTool` from the vector search
|
||||
- Wraps the single `search()` method, passing `search_type` parameter
|
||||
- Accepts: `name`, `description`, `search_type`, `top`, `skip`, `filter`, `string_mapper`
|
||||
- The tool takes a query string, vectorizes it, searches, and returns results as strings
|
||||
- Can also be a standalone factory function in `_vectors.py`
|
||||
|
||||
#### 3.9 — Tests for all vector store abstractions
|
||||
- Unit tests for enums, field types, collection definition
|
||||
- Unit tests for decorator
|
||||
- Unit tests for serialization/deserialization
|
||||
- Unit tests for record handler
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: In-Memory Vector Store
|
||||
**Goal:** Provide a zero-dependency vector store for testing and development.
|
||||
**Mergeable:** Yes — first usable vector store.
|
||||
|
||||
#### 4.1 — Port `InMemoryCollection` and `InMemoryStore` into core
|
||||
- Place in `agent_framework/_vectors.py` (alongside the abstractions)
|
||||
- Supports vector search (cosine similarity, etc.)
|
||||
- No external dependencies
|
||||
|
||||
#### 4.2 — Port FAISS extension (optional, can be separate package)
|
||||
- Extends InMemory with FAISS indexing
|
||||
|
||||
#### 4.3 — Tests and sample code
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Vector Store Connectors — Tier 1 (High Priority)
|
||||
**Goal:** Ship the most commonly used vector store connectors.
|
||||
**Mergeable:** Yes — each connector is independent.
|
||||
|
||||
Each connector follows the AF package structure:
|
||||
- New package under `packages/`
|
||||
- Own `pyproject.toml`, `tests/`, lazy loading in core
|
||||
|
||||
#### 5.1 — Azure AI Search (`packages/azure-ai-search/`)
|
||||
- May extend existing package or be new
|
||||
- `AzureAISearchCollection`, `AzureAISearchStore`
|
||||
|
||||
#### 5.2 — Qdrant (`packages/qdrant/`)
|
||||
- New package
|
||||
- `QdrantCollection`, `QdrantStore`
|
||||
|
||||
#### 5.3 — Redis (`packages/redis/`)
|
||||
- May extend existing redis package
|
||||
- `RedisCollection` (JSON + Hashset variants), `RedisStore`
|
||||
|
||||
#### 5.4 — PostgreSQL/pgvector (`packages/postgres/`)
|
||||
- New package
|
||||
- `PostgresCollection`, `PostgresStore`
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Vector Store Connectors — Tier 2
|
||||
**Goal:** Ship remaining vector store connectors.
|
||||
**Mergeable:** Yes — each connector is independent.
|
||||
|
||||
#### 6.1 — MongoDB Atlas (`packages/mongodb/`)
|
||||
#### 6.2 — Azure Cosmos DB (`packages/azure-cosmos-db/`)
|
||||
- Cosmos Mongo + Cosmos NoSQL
|
||||
#### 6.3 — Pinecone (`packages/pinecone/`)
|
||||
#### 6.4 — Chroma (`packages/chroma/`)
|
||||
#### 6.5 — Weaviate (`packages/weaviate/`)
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Vector Store Connectors — Tier 3
|
||||
**Goal:** Ship niche or less common connectors.
|
||||
**Mergeable:** Yes — each connector is independent.
|
||||
|
||||
#### 7.1 — Oracle (`packages/oracle/`)
|
||||
#### 7.2 — SQL Server (`packages/sql-server/`)
|
||||
#### 7.3 — FAISS (`packages/faiss/` or in core extending InMemory)
|
||||
|
||||
> **Note:** When implementing any SQL-based connector (PostgreSQL, SQL Server, SQLite, Cosmos DB), review the .NET MEVD changes made by @roji (Shay Rojansky) in SK for design patterns, query building, filter translation, and feature parity: https://github.com/microsoft/semantic-kernel/pulls?q=is%3Apr+author%3Aroji+is%3Aclosed
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Vector Store CRUD Tools
|
||||
**Goal:** Provide a full set of agent-usable tools for CRUD operations on vector store collections.
|
||||
**Mergeable:** Yes — adds tools without changing existing APIs.
|
||||
|
||||
#### 8.1 — `create_upsert_tool` — tool for upserting records into a collection
|
||||
#### 8.2 — `create_get_tool` — tool for retrieving records by key
|
||||
- Key-based lookup only (by primary key), not a search tool
|
||||
- Documentation must clearly distinguish this from `create_search_tool`: get_tool retrieves specific records by their known key, while search_tool performs similarity/filtered search across the collection
|
||||
- Consider if this overlaps with filtered search and document when to use which
|
||||
#### 8.3 — `create_delete_tool` — tool for deleting records by key
|
||||
#### 8.4 — Tests and samples for CRUD tools
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Additional Embedding Implementations (New Providers)
|
||||
**Goal:** Provide embedding generators for providers that don't yet have AF packages.
|
||||
**Mergeable:** Yes — each is independent, new packages.
|
||||
|
||||
#### 9.1 — HuggingFace/ONNX embedding (new package or lab)
|
||||
#### 9.2 — Mistral AI embedding (new package)
|
||||
#### 9.3 — Google AI / Vertex AI embedding (new package)
|
||||
#### 9.4 — Nvidia embedding (new package)
|
||||
|
||||
---
|
||||
|
||||
### Phase 10: TextSearch Abstractions & Implementations (Separate Work)
|
||||
**Goal:** Port text search (non-vector) abstractions and implementations.
|
||||
**Mergeable:** Yes — independent of vector stores.
|
||||
|
||||
#### 10.1 — TextSearch base class and types
|
||||
- `SearchOptions`, `SearchResponse`, `TextSearchResult`
|
||||
- `TextSearch` base class with `search()` method
|
||||
- `create_search_function()` for kernel integration (may need AF equivalent)
|
||||
|
||||
#### 10.2 — Brave Search implementation
|
||||
#### 10.3 — Google Search implementation
|
||||
#### 10.4 — Vector store text search bridge (connecting VectorSearch to TextSearch interface)
|
||||
|
||||
---
|
||||
|
||||
## Key Considerations
|
||||
|
||||
1. **No Pydantic for internal classes**: All AF internal classes should use plain classes. Pydantic is only used for user-facing input validation (e.g., vector store data models).
|
||||
|
||||
2. **Protocol + Base class**: Follow AF's pattern of both a `Protocol` for duck-typing and a `Base` ABC for implementation, matching how `SupportsChatGetResponse` + `BaseChatClient` works.
|
||||
|
||||
3. **Exception hierarchy**: Use AF's `IntegrationException` branch for vector store operations, since vector stores are external dependencies.
|
||||
|
||||
4. **`from __future__ import annotations`**: Required in all files per AF coding standard.
|
||||
|
||||
5. **No `**kwargs` escape hatches in public APIs**: For user-facing interfaces, use explicit named parameters per AF coding standard. Internal implementation details (e.g., cooperative multiple inheritance / MRO patterns) may use `**kwargs` where necessary, as long as they are not exposed in public signatures.
|
||||
|
||||
6. **Lazy loading**: Connector packages use `__getattr__` lazy loading in core provider folders.
|
||||
|
||||
7. **Reusable data models**: The `@vectorstoremodel` decorator and `VectorStoreCollectionDefinition` should be agnostic enough to work with both SK and AF. The core types (`FieldTypes`, `IndexKind`, `DistanceFunction`, `VectorStoreField`) should be identical or easily mapped.
|
||||
|
||||
8. **`create_search_tool`**: The AF-native equivalent of SK's `create_search_function`. Instead of creating a `KernelFunction`, this creates an AF `FunctionTool` (via the `@tool` decorator pattern) from a vector search. This allows agents to use vector search as a tool during conversations. Design:
|
||||
- `create_search_tool(name, description, search_type, ...)` → returns a `FunctionTool` that wraps `VectorSearch.search(search_type=...)`
|
||||
- The tool accepts a query string, performs embedding + vector search, and returns results as strings
|
||||
- Supports configurable string mappers, filter functions, top/skip defaults
|
||||
- Lives in `_vectors.py` as a method on `BaseVectorSearch` and/or as a standalone factory function
|
||||
|
||||
9. **CRUD tools**: A full set of create/read/update/delete tools for vector store collections, allowing agents to manage data in vector stores. Design:
|
||||
- `create_upsert_tool(...)` → tool for upserting records
|
||||
- `create_get_tool(...)` → tool for retrieving records by key
|
||||
- `create_delete_tool(...)` → tool for deleting records
|
||||
- These are separate from search and are placed in a later phase
|
||||
|
||||
10. **Score threshold filtering**: `SearchOptions` includes `score_threshold: float | None` to filter search results by relevance score (ref: [SK .NET PR #13501](https://github.com/microsoft/semantic-kernel/pull/13501)). The semantics depend on the distance function: for similarity functions (cosine similarity, dot product), results *below* the threshold are filtered out; for distance functions (cosine distance, euclidean), results *above* the threshold are filtered out. Use `DISTANCE_FUNCTION_DIRECTION_HELPER` to determine direction. Connectors should implement this natively where the database supports it, falling back to client-side post-filtering otherwise.
|
||||
@@ -29,6 +29,7 @@ using types like `IChatClient`, `FunctionInvokingChatClient`, `AITool`, `AIFunct
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **Encoding**: All new files must be saved with UTF-8 encoding with BOM (Byte Order Mark). This is required for `dotnet format` to work correctly.
|
||||
- **Copyright header**: `// Copyright (c) Microsoft. All rights reserved.` at top of all `.cs` files
|
||||
- **XML docs**: Required for all public methods and classes
|
||||
- **Async**: Use `Async` suffix for methods returning `Task`/`ValueTask`
|
||||
|
||||
@@ -111,9 +111,9 @@
|
||||
<!-- Identity -->
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
|
||||
<!-- Workflows -->
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.3.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.3.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.3.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.4.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.4.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.4.1" />
|
||||
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.8.1" />
|
||||
<!-- Durable Task -->
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Declarative/Agent_Step19_Declarative.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Agent_Step20_AdditionalAIContext.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/AgentSkills/">
|
||||
<File Path="samples/GettingStarted/AgentSkills/README.md" />
|
||||
<Project Path="samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/DeclarativeAgents/">
|
||||
<Project Path="samples/GettingStarted/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
</Folder>
|
||||
@@ -138,6 +142,7 @@
|
||||
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
|
||||
<File Path="samples/GettingStarted/AgentWithOpenAI/README.md" />
|
||||
@@ -176,8 +181,13 @@
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step19_OpenAPITools/FoundryAgents_Step19_OpenAPITools.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step18_FileSearch/FoundryAgents_Step18_FileSearch.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step19_OpenAPITools/FoundryAgents_Step19_OpenAPITools.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/FoundryAgents_Step21_BingCustomSearch.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step22_SharePoint/FoundryAgents_Step22_SharePoint.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/FoundryAgents_Step23_MicrosoftFabric.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step25_WebSearch/FoundryAgents_Step25_WebSearch.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step26_MemorySearch/FoundryAgents_Step26_MemorySearch.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj" />
|
||||
</Folder>
|
||||
@@ -218,6 +228,7 @@
|
||||
<Project Path="samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/Workflows/Declarative/Examples/">
|
||||
<File Path="../workflow-samples/CustomerSupport.yaml" />
|
||||
@@ -424,10 +435,12 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
|
||||
@@ -445,6 +458,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
|
||||
@@ -467,10 +481,12 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<RCNumber>2</RCNumber>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260219.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260219.1</PackageVersion>
|
||||
<GitTag>1.0.0-rc1</GitTag>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260225.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260225.1</PackageVersion>
|
||||
<GitTag>1.0.0-rc2</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<IsAotCompatible>false</IsAotCompatible>
|
||||
<TargetFrameworks>net10.0;net472</TargetFrameworks>
|
||||
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+14
@@ -5,6 +5,7 @@
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
@@ -21,3 +22,16 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate."));
|
||||
|
||||
// Create a responses based agent with "store"=false.
|
||||
// This means that chat history is managed locally by Agent Framework
|
||||
// instead of being stored in the service (default).
|
||||
AIAgent agentStoreFalse = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetResponsesClient(deploymentName)
|
||||
.AsIChatClientWithStoredOutputDisabled()
|
||||
.AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
Console.WriteLine(await agentStoreFalse.RunAsync("Tell me a joke about a pirate."));
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy skills directory to output -->
|
||||
<ItemGroup>
|
||||
<None Include="skills\**\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use Agent Skills with a ChatClientAgent.
|
||||
// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities.
|
||||
// Skills follow the progressive disclosure pattern: advertise -> load -> read resources.
|
||||
//
|
||||
// This sample includes the expense-report skill:
|
||||
// - Policy-based expense filing with references and assets
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
// --- Configuration ---
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// --- Skills Provider ---
|
||||
// Discovers skills from the 'skills' directory and makes them available to the agent
|
||||
var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills"));
|
||||
|
||||
// --- Agent Setup ---
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "SkillsAgent",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a helpful assistant.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
});
|
||||
|
||||
// --- Example 1: Expense policy question (loads FAQ resource) ---
|
||||
Console.WriteLine("Example 1: Checking expense policy FAQ");
|
||||
Console.WriteLine("---------------------------------------");
|
||||
AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered.");
|
||||
Console.WriteLine($"Agent: {response1.Text}\n");
|
||||
|
||||
// --- Example 2: Filing an expense report (multi-turn with template asset) ---
|
||||
Console.WriteLine("Example 2: Filing an expense report");
|
||||
Console.WriteLine("---------------------------------------");
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.",
|
||||
session);
|
||||
Console.WriteLine($"Agent: {response2.Text}\n");
|
||||
@@ -0,0 +1,63 @@
|
||||
# Agent Skills Sample
|
||||
|
||||
This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework.
|
||||
|
||||
## What are Agent Skills?
|
||||
|
||||
Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern:
|
||||
|
||||
1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
|
||||
2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
|
||||
3. **Resources**: References and other files loaded via `read_skill_resource` tool
|
||||
|
||||
## Skills Included
|
||||
|
||||
### expense-report
|
||||
Policy-based expense filing with spending limits, receipt requirements, and approval workflows.
|
||||
- `references/POLICY_FAQ.md` — Detailed expense policy Q&A
|
||||
- `assets/expense-report-template.md` — Submission template
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Agent_Step01_BasicSkills/
|
||||
├── Program.cs
|
||||
├── Agent_Step01_BasicSkills.csproj
|
||||
└── skills/
|
||||
└── expense-report/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ └── POLICY_FAQ.md
|
||||
└── assets/
|
||||
└── expense-report-template.md
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Prerequisites
|
||||
- .NET 10.0 SDK
|
||||
- Azure OpenAI endpoint with a deployed model
|
||||
|
||||
### Setup
|
||||
1. Set environment variables:
|
||||
```bash
|
||||
export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
|
||||
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
```
|
||||
|
||||
2. Run the sample:
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
The sample runs two examples:
|
||||
|
||||
1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource
|
||||
2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Agent Skills Specification](https://agentskills.io/)
|
||||
- [Microsoft Agent Framework Documentation](../../../../../docs/)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: expense-report
|
||||
description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories.
|
||||
metadata:
|
||||
author: contoso-finance
|
||||
version: "2.1"
|
||||
---
|
||||
|
||||
# Expense Report
|
||||
|
||||
## Categories and Limits
|
||||
|
||||
| Category | Limit | Receipt | Approval |
|
||||
|---|---|---|---|
|
||||
| Meals — solo | $50/day | >$25 | No |
|
||||
| Meals — team/client | $75/person | Always | Manager if >$200 total |
|
||||
| Lodging | $250/night | Always | Manager if >3 nights |
|
||||
| Ground transport | $100/day | >$15 | No |
|
||||
| Airfare | Economy | Always | Manager; VP if >$1,500 |
|
||||
| Conference/training | $2,000/event | Always | Manager + L&D |
|
||||
| Office supplies | $100 | Yes | No |
|
||||
| Software/subscriptions | $50/month | Yes | Manager if >$200/year |
|
||||
|
||||
## Filing Process
|
||||
|
||||
1. Collect receipts — must show vendor, date, amount, payment method.
|
||||
2. Categorize per table above.
|
||||
3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md).
|
||||
4. For client/team meals: list attendee names and business purpose.
|
||||
5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000.
|
||||
6. Reimbursement: 10 business days via direct deposit.
|
||||
|
||||
## Policy Rules
|
||||
|
||||
- Submit within 30 days of transaction.
|
||||
- Alcohol is never reimbursable.
|
||||
- Foreign currency: convert to USD at transaction-date rate; note original currency and amount.
|
||||
- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes.
|
||||
- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter.
|
||||
- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# Expense Report Template
|
||||
|
||||
| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached |
|
||||
|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------|
|
||||
| | | | | | | | | | Yes or No |
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Expense Policy — Frequently Asked Questions
|
||||
|
||||
## Meals
|
||||
|
||||
**Q: Can I expense coffee or snacks during the workday?**
|
||||
A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal.
|
||||
|
||||
**Q: What if a team dinner exceeds the per-person limit?**
|
||||
A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP.
|
||||
|
||||
**Q: Do I need to list every attendee?**
|
||||
A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list.
|
||||
|
||||
## Travel
|
||||
|
||||
**Q: Can I book a premium economy or business class flight?**
|
||||
A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation.
|
||||
|
||||
**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?**
|
||||
A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people.
|
||||
|
||||
**Q: Are tips reimbursable?**
|
||||
A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification.
|
||||
|
||||
## Lodging
|
||||
|
||||
**Q: What if the $250/night limit isn't enough for the city I'm visiting?**
|
||||
A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking.
|
||||
|
||||
**Q: Can I stay with friends/family instead and get a per-diem?**
|
||||
A: No. Contoso reimburses actual lodging costs only, not per-diems.
|
||||
|
||||
## Subscriptions and Software
|
||||
|
||||
**Q: Can I expense a personal productivity tool?**
|
||||
A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing.
|
||||
|
||||
**Q: What about annual subscriptions?**
|
||||
A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report.
|
||||
|
||||
## Receipts and Documentation
|
||||
|
||||
**Q: My receipt is faded/damaged. What do I do?**
|
||||
A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter.
|
||||
|
||||
**Q: Do I need a receipt for parking meters or tolls?**
|
||||
A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required.
|
||||
|
||||
## Approval and Reimbursement
|
||||
|
||||
**Q: My manager is on leave. Who approves my report?**
|
||||
A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system.
|
||||
|
||||
**Q: Can I submit expenses from a previous quarter?**
|
||||
A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval.
|
||||
@@ -0,0 +1,7 @@
|
||||
# AgentSkills Samples
|
||||
|
||||
Samples demonstrating Agent Skills capabilities.
|
||||
|
||||
| Sample | Description |
|
||||
|--------|-------------|
|
||||
| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources |
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use the FoundryMemoryProvider to persist and recall memories for an agent.
|
||||
// The sample stores conversation messages in an Azure AI Foundry memory store and retrieves relevant
|
||||
// memories for subsequent invocations, even across new sessions.
|
||||
//
|
||||
// Note: Memory extraction in Azure AI Foundry is asynchronous and takes time. This sample demonstrates
|
||||
// a simple polling approach to wait for memory updates to complete before querying.
|
||||
|
||||
using System.Text.Json;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.FoundryMemory;
|
||||
|
||||
string foundryEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string memoryStoreName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MEMORY_STORE_NAME") ?? "memory-store-sample";
|
||||
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MODEL") ?? "gpt-4.1-mini";
|
||||
string embeddingModelName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_EMBEDDING_MODEL") ?? "text-embedding-ada-002";
|
||||
|
||||
// Create an AIProjectClient for Foundry with Azure Identity authentication.
|
||||
DefaultAzureCredential credential = new();
|
||||
AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential);
|
||||
|
||||
// Get the ChatClient from the AIProjectClient's OpenAI property using the deployment name.
|
||||
// The stateInitializer can be used to customize the Foundry Memory scope per session and it will be called each time a session
|
||||
// is encountered by the FoundryMemoryProvider that does not already have state stored on the session.
|
||||
// If each session should have its own scope, you can create a new id per session via the stateInitializer, e.g.:
|
||||
// new FoundryMemoryProvider(projectClient, memoryStoreName, stateInitializer: _ => new(new FoundryMemoryProviderScope(Guid.NewGuid().ToString())), ...)
|
||||
// In our case we are storing memories scoped by user so that memories are retained across sessions.
|
||||
FoundryMemoryProvider memoryProvider = new(
|
||||
projectClient,
|
||||
memoryStoreName,
|
||||
stateInitializer: _ => new(new FoundryMemoryProviderScope("sample-user-123")));
|
||||
|
||||
AIAgent agent = await projectClient.CreateAIAgentAsync(deploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
Name = "TravelAssistantWithFoundryMemory",
|
||||
ChatOptions = new() { Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." },
|
||||
AIContextProviders = [memoryProvider]
|
||||
});
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
Console.WriteLine("\n>> Setting up Foundry Memory Store\n");
|
||||
|
||||
// Ensure the memory store exists (creates it with the specified models if needed).
|
||||
await memoryProvider.EnsureMemoryStoreCreatedAsync(deploymentName, embeddingModelName, "Sample memory store for travel assistant");
|
||||
|
||||
// Clear any existing memories for this scope to demonstrate fresh behavior.
|
||||
await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", session));
|
||||
Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", session));
|
||||
|
||||
// Memory extraction in Azure AI Foundry is asynchronous and takes time to process.
|
||||
// WhenUpdatesCompletedAsync polls all pending updates and waits for them to complete.
|
||||
Console.WriteLine("\nWaiting for Foundry Memory to process updates...");
|
||||
await memoryProvider.WhenUpdatesCompletedAsync();
|
||||
|
||||
Console.WriteLine("Updates completed.\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", session));
|
||||
|
||||
Console.WriteLine("\n>> Serialize and deserialize the session to demonstrate persisted state\n");
|
||||
JsonElement serializedSession = await agent.SerializeSessionAsync(session);
|
||||
AgentSession restoredSession = await agent.DeserializeSessionAsync(serializedSession);
|
||||
Console.WriteLine(await agent.RunAsync("Can you recap the personal details you remember?", restoredSession));
|
||||
|
||||
Console.WriteLine("\n>> Start a new session that shares the same Foundry Memory scope\n");
|
||||
|
||||
Console.WriteLine("\nWaiting for Foundry Memory to process updates...");
|
||||
await memoryProvider.WhenUpdatesCompletedAsync();
|
||||
|
||||
AgentSession newSession = await agent.CreateSessionAsync();
|
||||
Console.WriteLine(await agent.RunAsync("Summarize what you already know about me.", newSession));
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Agent with Memory Using Azure AI Foundry
|
||||
|
||||
This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories across sessions.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
- Creating a `FoundryMemoryProvider` with Azure Identity authentication
|
||||
- Automatic memory store creation if it doesn't exist
|
||||
- Multi-turn conversations with automatic memory extraction
|
||||
- Memory retrieval to inform agent responses
|
||||
- Session serialization and deserialization
|
||||
- Memory persistence across completely new sessions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Azure subscription with Azure AI Foundry project
|
||||
2. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) and an embedding model deployment (e.g., text-embedding-ada-002)
|
||||
3. .NET 10.0 SDK
|
||||
4. Azure CLI logged in (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Azure AI Foundry project endpoint and memory store name
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project"
|
||||
export FOUNDRY_PROJECT_MEMORY_STORE_NAME="my_memory_store"
|
||||
|
||||
# Model deployment names (models deployed in your Foundry project)
|
||||
export FOUNDRY_PROJECT_MODEL="gpt-4o-mini"
|
||||
export FOUNDRY_PROJECT_EMBEDDING_MODEL="text-embedding-ada-002"
|
||||
```
|
||||
|
||||
## Run the Sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
The agent will:
|
||||
1. Create the memory store if it doesn't exist (using the specified chat and embedding models)
|
||||
2. Learn your name (Taylor), travel destination (Patagonia), timing (November), companions (sister), and interests (scenic viewpoints)
|
||||
3. Wait for Foundry Memory to index the memories
|
||||
4. Recall those details when asked about the trip
|
||||
5. Demonstrate memory persistence across session serialization/deserialization
|
||||
6. Show that a brand new session can still access the same memories
|
||||
|
||||
## Key Differences from Mem0
|
||||
|
||||
| Aspect | Mem0 | Azure AI Foundry Memory |
|
||||
|--------|------|------------------------|
|
||||
| Authentication | API Key | Azure Identity (DefaultAzureCredential) |
|
||||
| Scope | ApplicationId, UserId, AgentId, ThreadId | Single `Scope` string |
|
||||
| Memory Types | Single memory store | User Profile + Chat Summary |
|
||||
| Hosting | Mem0 cloud or self-hosted | Azure AI Foundry managed service |
|
||||
| Store Creation | N/A (automatic) | Explicit via `EnsureMemoryStoreCreatedAsync` |
|
||||
@@ -7,3 +7,6 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|
||||
|[Chat History memory](./AgentWithMemory_Step01_ChatHistoryMemory/)|This sample demonstrates how to enable an agent to remember messages from previous conversations.|
|
||||
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
|
||||
|[Custom Memory Implementation](./AgentWithMemory_Step03_CustomMemory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|
||||
|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.|
|
||||
|
||||
> **See also**: [Memory Search with Foundry Agents](../FoundryAgents/FoundryAgents_Step26_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry Agents.
|
||||
|
||||
@@ -105,12 +105,27 @@ Console.WriteLine("\n\n=== Example 5: MessageAIContextProvider middleware ===");
|
||||
|
||||
var contextProviderAgent = originalAgent
|
||||
.AsBuilder()
|
||||
.Use([new DateTimeContextProvider()])
|
||||
.UseAIContextProviders(new DateTimeContextProvider())
|
||||
.Build();
|
||||
|
||||
var contextResponse = await contextProviderAgent.RunAsync("Is it almost time for lunch?");
|
||||
Console.WriteLine($"Context-enriched response: {contextResponse}");
|
||||
|
||||
// AIContextProvider at the chat client level. Unlike the agent-level MessageAIContextProvider,
|
||||
// this operates within the IChatClient pipeline and can also enrich tools and instructions.
|
||||
// It must be used within the context of a running AIAgent (uses AIAgent.CurrentRunContext).
|
||||
// In this case we are attaching an AIContextProvider that only adds messages.
|
||||
Console.WriteLine("\n\n=== Example 6: AIContextProvider on chat client pipeline ===");
|
||||
|
||||
var chatClientProviderAgent = azureOpenAIClient.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.UseAIContextProviders(new DateTimeContextProvider())
|
||||
.BuildAIAgent(
|
||||
instructions: "You are an AI assistant that helps people find information.");
|
||||
|
||||
var chatClientContextResponse = await chatClientProviderAgent.RunAsync("Is it almost time for lunch?");
|
||||
Console.WriteLine($"Chat client context-enriched response: {chatClientContextResponse}");
|
||||
|
||||
// Function invocation middleware that logs before and after function calls.
|
||||
async ValueTask<object?> FunctionCallMiddleware(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -278,7 +293,7 @@ async Task<ChatResponse> PerRequestChatClientMiddleware(IEnumerable<ChatMessage>
|
||||
/// <summary>
|
||||
/// A <see cref="MessageAIContextProvider"/> that injects the current date and time into the agent's context.
|
||||
/// This is a simple example of how to use a MessageAIContextProvider to enrich agent messages
|
||||
/// via the <see cref="AIAgentBuilder.Use(MessageAIContextProvider[])"/> extension method.
|
||||
/// via the <see cref="AIAgentBuilder.UseAIContextProviders(MessageAIContextProvider[])"/> extension method.
|
||||
/// </summary>
|
||||
internal sealed class DateTimeContextProvider : MessageAIContextProvider
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ This sample demonstrates how to add middleware to intercept:
|
||||
6. Per‑request function pipeline with approval
|
||||
7. Combining agent‑level and per‑request middleware
|
||||
8. MessageAIContextProvider middleware via `AIAgentBuilder.Use(...)` for injecting additional context messages
|
||||
9. AIContextProvider middleware via `ChatClientBuilder.Use(...)` for enriching messages, tools, and instructions at the chat client level
|
||||
|
||||
## Function Invocation Middleware
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use Bing Custom Search Tool with AI Agents.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string connectionId = Environment.GetEnvironmentVariable("BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID is not set.");
|
||||
string instanceName = Environment.GetEnvironmentVariable("BING_CUSTOM_SEARCH_INSTANCE_NAME") ?? throw new InvalidOperationException("BING_CUSTOM_SEARCH_INSTANCE_NAME is not set.");
|
||||
|
||||
const string AgentInstructions = """
|
||||
You are a helpful agent that can use Bing Custom Search tools to assist users.
|
||||
Use the available Bing Custom Search tools to answer questions and perform tasks.
|
||||
""";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Bing Custom Search tool parameters shared by both options
|
||||
BingCustomSearchToolParameters bingCustomSearchToolParameters = new([
|
||||
new BingCustomSearchConfiguration(connectionId, instanceName)
|
||||
]);
|
||||
|
||||
AIAgent agent = await CreateAgentWithMEAIAsync();
|
||||
// AIAgent agent = await CreateAgentWithNativeSDKAsync();
|
||||
|
||||
Console.WriteLine($"Created agent: {agent.Name}");
|
||||
|
||||
// Run the agent with a search query
|
||||
AgentResponse response = await agent.RunAsync("Search for the latest news about Microsoft AI");
|
||||
|
||||
Console.WriteLine("\n=== Agent Response ===");
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
Console.WriteLine(message.Text);
|
||||
}
|
||||
|
||||
// Cleanup by deleting the agent
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
Console.WriteLine($"\nDeleted agent: {agent.Name}");
|
||||
|
||||
// --- Agent Creation Options ---
|
||||
|
||||
// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateBingCustomSearchTool (MEAI + AgentFramework)
|
||||
async Task<AIAgent> CreateAgentWithMEAIAsync()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
model: deploymentName,
|
||||
name: "BingCustomSearchAgent-MEAI",
|
||||
instructions: AgentInstructions,
|
||||
tools: [((ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters)).AsAITool()]);
|
||||
}
|
||||
|
||||
// Option 2 - Using PromptAgentDefinition with AgentTool.CreateBingCustomSearchTool (Native SDK)
|
||||
async Task<AIAgent> CreateAgentWithNativeSDKAsync()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
name: "BingCustomSearchAgent-NATIVE",
|
||||
creationOptions: new AgentVersionCreationOptions(
|
||||
new PromptAgentDefinition(model: deploymentName)
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = {
|
||||
(ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters),
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# Using Bing Custom Search with AI Agents
|
||||
|
||||
This sample demonstrates how to use the Bing Custom Search tool with AI agents to perform customized web searches.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Creating agents with Bing Custom Search capabilities
|
||||
- Configuring custom search instances via connection ID and instance name
|
||||
- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2)
|
||||
- Running search queries through the agent
|
||||
- Managing agent lifecycle (creation and deletion)
|
||||
|
||||
## Agent creation options
|
||||
|
||||
This sample provides two approaches for creating agents with Bing Custom Search:
|
||||
|
||||
- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2.
|
||||
- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types.
|
||||
|
||||
Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure Foundry service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- A Bing Custom Search resource configured in Azure and connected to your Foundry project
|
||||
|
||||
**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource.
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
$env:BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID="/subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<account>/projects/<project>/connections/<connection-name>"
|
||||
$env:BING_CUSTOM_SEARCH_INSTANCE_NAME="your-configuration-name"
|
||||
```
|
||||
|
||||
### Finding the connection ID and instance name
|
||||
|
||||
- **Connection ID**: The full ARM resource path including the `/projects/<name>/connections/<connection-name>` segment. Find the connection name in your Foundry project under **Management center** → **Connected resources**.
|
||||
- **Instance Name**: The **configuration name** from the Bing Custom Search resource (Azure portal → your Bing Custom Search resource → **Configurations**). This is _not_ the Azure resource name.
|
||||
|
||||
## Run the sample
|
||||
|
||||
Navigate to the FoundryAgents sample directory and run:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/GettingStarted/FoundryAgents
|
||||
dotnet run --project .\FoundryAgents_Step21_BingCustomSearch
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
The sample will:
|
||||
|
||||
1. Create an agent with Bing Custom Search tool capabilities
|
||||
2. Run the agent with a search query about Microsoft AI
|
||||
3. Display the search results returned by the agent
|
||||
4. Clean up resources by deleting the agent
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use SharePoint Grounding Tool with AI Agents.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string sharepointConnectionId = Environment.GetEnvironmentVariable("SHAREPOINT_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("SHAREPOINT_PROJECT_CONNECTION_ID is not set.");
|
||||
|
||||
const string AgentInstructions = """
|
||||
You are a helpful agent that can use SharePoint tools to assist users.
|
||||
Use the available SharePoint tools to answer questions and perform tasks.
|
||||
""";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Create SharePoint tool options with project connection
|
||||
var sharepointOptions = new SharePointGroundingToolOptions();
|
||||
sharepointOptions.ProjectConnections.Add(new ToolProjectConnection(sharepointConnectionId));
|
||||
|
||||
AIAgent agent = await CreateAgentWithMEAIAsync();
|
||||
// AIAgent agent = await CreateAgentWithNativeSDKAsync();
|
||||
|
||||
Console.WriteLine($"Created agent: {agent.Name}");
|
||||
|
||||
AgentResponse response = await agent.RunAsync("List the documents available in SharePoint");
|
||||
|
||||
// Display the response
|
||||
Console.WriteLine("\n=== Agent Response ===");
|
||||
Console.WriteLine(response);
|
||||
|
||||
// Display grounding annotations if any
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
if (content.Annotations is not null)
|
||||
{
|
||||
foreach (var annotation in content.Annotations)
|
||||
{
|
||||
Console.WriteLine($"Annotation: {annotation}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup by agent name removes the agent version created.
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
Console.WriteLine($"\nDeleted agent: {agent.Name}");
|
||||
|
||||
// --- Agent Creation Options ---
|
||||
|
||||
// Option 1 - Using AgentTool.CreateSharepointTool + AsAITool() (MEAI + AgentFramework)
|
||||
async Task<AIAgent> CreateAgentWithMEAIAsync()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
model: deploymentName,
|
||||
name: "SharePointAgent-MEAI",
|
||||
instructions: AgentInstructions,
|
||||
tools: [((ResponseTool)AgentTool.CreateSharepointTool(sharepointOptions)).AsAITool()]);
|
||||
}
|
||||
|
||||
// Option 2 - Using PromptAgentDefinition SDK native type
|
||||
async Task<AIAgent> CreateAgentWithNativeSDKAsync()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
name: "SharePointAgent-NATIVE",
|
||||
creationOptions: new AgentVersionCreationOptions(
|
||||
new PromptAgentDefinition(model: deploymentName)
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = { AgentTool.CreateSharepointTool(sharepointOptions) }
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Using SharePoint Grounding with AI Agents
|
||||
|
||||
This sample demonstrates how to use the SharePoint grounding tool with AI agents. The SharePoint grounding tool enables agents to search and retrieve information from SharePoint sites.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Creating agents with SharePoint grounding capabilities
|
||||
- Using AgentTool.CreateSharepointTool (MEAI abstraction)
|
||||
- Using native SDK SharePoint tools (PromptAgentDefinition)
|
||||
- Managing agent lifecycle (creation and deletion)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure Foundry service endpoint and deployment configured
|
||||
- Azure authentication configured for `DefaultAzureCredential` (for example, Azure CLI logged in with `az login`, environment variables, managed identity, or IDE sign-in)
|
||||
- A SharePoint project connection configured in Azure Foundry
|
||||
|
||||
**Note**: This demo uses `DefaultAzureCredential` for authentication. This credential will try multiple authentication mechanisms in order (such as environment variables, managed identity, Azure CLI login, and IDE sign-in) and use the first one that works. A common option for local development is to sign in with the Azure CLI using `az login` and ensure you have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively) and the [DefaultAzureCredential documentation](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
$env:SHAREPOINT_PROJECT_CONNECTION_ID="your-sharepoint-connection-id" # Required: SharePoint project connection ID
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
Navigate to the FoundryAgents sample directory and run:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/GettingStarted/FoundryAgents
|
||||
dotnet run --project .\FoundryAgents_Step22_SharePoint
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
The sample will:
|
||||
|
||||
1. Create two agents with SharePoint grounding capabilities:
|
||||
- Option 1: Using AgentTool.CreateSharepointTool (MEAI abstraction)
|
||||
- Option 2: Using native SDK SharePoint tools
|
||||
2. Run the agent with a query: "List the documents available in SharePoint"
|
||||
3. The agent will use SharePoint grounding to search and retrieve relevant documents
|
||||
4. Display the response and any grounding annotations
|
||||
5. Clean up resources by deleting both agents
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use Microsoft Fabric Tool with AI Agents.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string fabricConnectionId = Environment.GetEnvironmentVariable("FABRIC_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("FABRIC_PROJECT_CONNECTION_ID is not set.");
|
||||
|
||||
const string AgentInstructions = "You are a helpful assistant with access to Microsoft Fabric data. Answer questions based on data available through your Fabric connection.";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Configure Microsoft Fabric tool options with project connection
|
||||
var fabricToolOptions = new FabricDataAgentToolOptions();
|
||||
fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection(fabricConnectionId));
|
||||
|
||||
AIAgent agent = await CreateAgentWithMEAIAsync();
|
||||
// AIAgent agent = await CreateAgentWithNativeSDKAsync();
|
||||
|
||||
Console.WriteLine($"Created agent: {agent.Name}");
|
||||
|
||||
// Run the agent with a sample query
|
||||
AgentResponse response = await agent.RunAsync("What data is available in the connected Fabric workspace?");
|
||||
|
||||
Console.WriteLine("\n=== Agent Response ===");
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
Console.WriteLine(message.Text);
|
||||
}
|
||||
|
||||
// Cleanup by deleting the agent
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
Console.WriteLine($"\nDeleted agent: {agent.Name}");
|
||||
|
||||
// --- Agent Creation Options ---
|
||||
|
||||
// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateMicrosoftFabricTool (MEAI + AgentFramework)
|
||||
async Task<AIAgent> CreateAgentWithMEAIAsync()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
model: deploymentName,
|
||||
name: "FabricAgent-MEAI",
|
||||
instructions: AgentInstructions,
|
||||
tools: [((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)).AsAITool()]);
|
||||
}
|
||||
|
||||
// Option 2 - Using PromptAgentDefinition with AgentTool.CreateMicrosoftFabricTool (Native SDK)
|
||||
async Task<AIAgent> CreateAgentWithNativeSDKAsync()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
name: "FabricAgent-NATIVE",
|
||||
creationOptions: new AgentVersionCreationOptions(
|
||||
new PromptAgentDefinition(model: deploymentName)
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools =
|
||||
{
|
||||
AgentTool.CreateMicrosoftFabricTool(fabricToolOptions),
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Using Microsoft Fabric Tool with AI Agents
|
||||
|
||||
This sample demonstrates how to use the Microsoft Fabric tool with AI Agents, allowing agents to query and interact with data in Microsoft Fabric workspaces.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Creating agents with Microsoft Fabric data access capabilities
|
||||
- Using FabricDataAgentToolOptions to configure Fabric connections
|
||||
- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2)
|
||||
- Managing agent lifecycle (creation and deletion)
|
||||
|
||||
## Agent creation options
|
||||
|
||||
This sample provides two approaches for creating agents with Microsoft Fabric:
|
||||
|
||||
- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2.
|
||||
- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types.
|
||||
|
||||
Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure Foundry service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- A Microsoft Fabric workspace with a configured project connection in Azure Foundry
|
||||
|
||||
**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource.
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
$env:FABRIC_PROJECT_CONNECTION_ID="your-fabric-connection-id" # The Fabric project connection ID from Azure Foundry
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
Navigate to the FoundryAgents sample directory and run:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/GettingStarted/FoundryAgents
|
||||
dotnet run --project .\FoundryAgents_Step23_MicrosoftFabric
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
The sample will:
|
||||
|
||||
1. Create an agent with Microsoft Fabric tool capabilities
|
||||
2. Configure the agent with a Fabric project connection
|
||||
3. Run the agent with a query about available Fabric data
|
||||
4. Display the agent's response
|
||||
5. Clean up resources by deleting the agent
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use the Responses API Web Search Tool with AI Agents.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
const string AgentInstructions = "You are a helpful assistant that can search the web to find current information and answer questions accurately.";
|
||||
const string AgentName = "WebSearchAgent";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Option 1 - Using HostedWebSearchTool (MEAI + AgentFramework)
|
||||
AIAgent agent = await CreateAgentWithMEAIAsync();
|
||||
|
||||
// Option 2 - Using PromptAgentDefinition with the Responses API native type
|
||||
// AIAgent agent = await CreateAgentWithNativeSDKAsync();
|
||||
|
||||
AgentResponse response = await agent.RunAsync("What's the weather today in Seattle?");
|
||||
|
||||
// Get the text response
|
||||
Console.WriteLine($"Response: {response.Text}");
|
||||
|
||||
// Getting any annotations/citations generated by the web search tool
|
||||
foreach (AIAnnotation annotation in response.Messages.SelectMany(m => m.Contents).SelectMany(c => c.Annotations ?? []))
|
||||
{
|
||||
Console.WriteLine($"Annotation: {annotation}");
|
||||
if (annotation.RawRepresentation is UriCitationMessageAnnotation urlCitation)
|
||||
{
|
||||
Console.WriteLine($$"""
|
||||
Title: {{urlCitation.Title}}
|
||||
URL: {{urlCitation.Uri}}
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup by agent name removes the agent version created.
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
|
||||
// Creates the agent using the HostedWebSearchTool MEAI abstraction that maps to the built-in Responses API web search tool.
|
||||
async Task<AIAgent> CreateAgentWithMEAIAsync()
|
||||
=> await aiProjectClient.CreateAIAgentAsync(
|
||||
name: AgentName,
|
||||
model: deploymentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new HostedWebSearchTool()]);
|
||||
|
||||
// Creates the agent using the PromptAgentDefinition with the Responses API native ResponseTool.CreateWebSearchTool().
|
||||
async Task<AIAgent> CreateAgentWithNativeSDKAsync()
|
||||
=> await aiProjectClient.CreateAIAgentAsync(
|
||||
AgentName,
|
||||
new AgentVersionCreationOptions(
|
||||
new PromptAgentDefinition(model: deploymentName)
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = { ResponseTool.CreateWebSearchTool() }
|
||||
}));
|
||||
@@ -0,0 +1,52 @@
|
||||
# Using Web Search with AI Agents
|
||||
|
||||
This sample demonstrates how to use the Responses API web search tool with AI agents. The web search tool allows agents to search the web for current information to answer questions accurately.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Creating agents with web search capabilities
|
||||
- Using HostedWebSearchTool (MEAI abstraction)
|
||||
- Using native SDK web search tools (ResponseTool.CreateWebSearchTool)
|
||||
- Extracting text responses and URL citations from agent responses
|
||||
- Managing agent lifecycle (creation and deletion)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure Foundry service endpoint and deployment configured
|
||||
- Azure authentication configured for `DefaultAzureCredential` (for example, Azure CLI logged in with `az login`, environment variables, managed identity, or IDE sign-in)
|
||||
|
||||
**Note**: This sample authenticates using `DefaultAzureCredential` from the Azure Identity library, which will try several credential sources (including Azure CLI, environment variables, managed identity, and IDE sign-in). Ensure at least one supported credential source is available. For more information, see the [Azure Identity documentation](https://learn.microsoft.com/dotnet/api/overview/azure/identity-readme).
|
||||
|
||||
**Note**: The web search tool uses the built-in web search capability from the OpenAI Responses API.
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
Navigate to the FoundryAgents sample directory and run:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/GettingStarted/FoundryAgents
|
||||
dotnet run --project .\FoundryAgents_Step25_WebSearch
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
The sample will:
|
||||
|
||||
1. Create an agent with web search capabilities using HostedWebSearchTool (MEAI abstraction)
|
||||
- Alternative: Using native SDK web search tools (commented out in code)
|
||||
- Alternative: Retrieving an existing agent by name (commented out in code)
|
||||
2. Run the agent with a query: "What's the weather today in Seattle?"
|
||||
3. The agent will use the web search tool to find current information
|
||||
4. Display the text response from the agent
|
||||
5. Display any URL citations from web search results
|
||||
6. Clean up resources by deleting the agent
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);CA1812</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.AI.Projects.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the Memory Search Tool with AI Agents.
|
||||
// The Memory Search Tool enables agents to recall information from previous conversations,
|
||||
// supporting user profile persistence and chat summaries across sessions.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// Memory store configuration
|
||||
// NOTE: Memory stores must be created beforehand via Azure Portal or Python SDK.
|
||||
// The .NET SDK currently only supports using existing memory stores with agents.
|
||||
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_MEMORY_STORE_NAME") ?? throw new InvalidOperationException("AZURE_FOUNDRY_MEMORY_STORE_NAME is not set.");
|
||||
|
||||
const string AgentInstructions = """
|
||||
You are a helpful assistant that remembers past conversations.
|
||||
Use the memory search tool to recall relevant information from previous interactions.
|
||||
When a user shares personal details or preferences, remember them for future conversations.
|
||||
""";
|
||||
|
||||
const string AgentNameMEAI = "MemorySearchAgent-MEAI";
|
||||
const string AgentNameNative = "MemorySearchAgent-NATIVE";
|
||||
|
||||
// Scope identifies the user or context for memory isolation.
|
||||
// Using a unique user identifier ensures memories are private to that user.
|
||||
string userScope = $"user_{Environment.MachineName}";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Create the Memory Search tool configuration
|
||||
MemorySearchTool memorySearchTool = new(memoryStoreName, userScope)
|
||||
{
|
||||
// Optional: Configure how quickly new memories are indexed (in seconds)
|
||||
UpdateDelay = 1,
|
||||
|
||||
// Optional: Configure search behavior
|
||||
SearchOptions = new MemorySearchToolOptions
|
||||
{
|
||||
// Additional search options can be configured here if needed
|
||||
}
|
||||
};
|
||||
|
||||
// Create agent using Option 1 (MEAI) or Option 2 (Native SDK)
|
||||
AIAgent agent = await CreateAgentWithMEAI();
|
||||
// AIAgent agent = await CreateAgentWithNativeSDK();
|
||||
|
||||
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
|
||||
|
||||
// Conversation 1: Share some personal information
|
||||
Console.WriteLine("User: My name is Alice and I love programming in C#.");
|
||||
AgentResponse response1 = await agent.RunAsync("My name is Alice and I love programming in C#.");
|
||||
Console.WriteLine($"Agent: {response1.Messages.LastOrDefault()?.Text}\n");
|
||||
|
||||
// Allow time for memory to be indexed
|
||||
await Task.Delay(2000);
|
||||
|
||||
// Conversation 2: Test if the agent remembers
|
||||
Console.WriteLine("User: What's my name and what programming language do I prefer?");
|
||||
AgentResponse response2 = await agent.RunAsync("What's my name and what programming language do I prefer?");
|
||||
Console.WriteLine($"Agent: {response2.Messages.LastOrDefault()?.Text}\n");
|
||||
|
||||
// Inspect memory search results if available in raw response items
|
||||
// Note: Memory search tool call results appear as AgentResponseItem types
|
||||
foreach (var message in response2.Messages)
|
||||
{
|
||||
if (message.RawRepresentation is AgentResponseItem agentResponseItem &&
|
||||
agentResponseItem is MemorySearchToolCallResponseItem memorySearchResult)
|
||||
{
|
||||
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
|
||||
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
|
||||
|
||||
foreach (var result in memorySearchResult.Results)
|
||||
{
|
||||
var memoryItem = result.MemoryItem;
|
||||
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
|
||||
Console.WriteLine($" Scope: {memoryItem.Scope}");
|
||||
Console.WriteLine($" Content: {memoryItem.Content}");
|
||||
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: Delete the agent (memory store persists and should be cleaned up separately if needed)
|
||||
Console.WriteLine("\nCleaning up agent...");
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
Console.WriteLine("Agent deleted successfully.");
|
||||
|
||||
// NOTE: Memory stores are long-lived resources and are NOT deleted with the agent.
|
||||
// To delete a memory store, use the Azure Portal or Python SDK:
|
||||
// await project_client.memory_stores.delete(memory_store.name)
|
||||
|
||||
// --- Agent Creation Options ---
|
||||
#pragma warning disable CS8321 // Local function is declared but never used
|
||||
|
||||
// Option 1 - Using MemorySearchTool wrapped as MEAI AITool
|
||||
async Task<AIAgent> CreateAgentWithMEAI()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
model: deploymentName,
|
||||
name: AgentNameMEAI,
|
||||
instructions: AgentInstructions,
|
||||
tools: [((ResponseTool)memorySearchTool).AsAITool()]);
|
||||
}
|
||||
|
||||
// Option 2 - Using PromptAgentDefinition with MemorySearchTool (Native SDK)
|
||||
async Task<AIAgent> CreateAgentWithNativeSDK()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
name: AgentNameNative,
|
||||
creationOptions: new AgentVersionCreationOptions(
|
||||
new PromptAgentDefinition(model: deploymentName)
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = { memorySearchTool }
|
||||
})
|
||||
);
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# Using Memory Search with AI Agents
|
||||
|
||||
This sample demonstrates how to use the Memory Search tool with AI agents. The Memory Search tool enables agents to recall information from previous conversations, supporting user profile persistence and chat summaries across sessions.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Creating an agent with Memory Search tool capabilities
|
||||
- Configuring memory scope for user isolation
|
||||
- Having conversations where the agent remembers past information
|
||||
- Inspecting memory search results from agent responses
|
||||
- Managing agent lifecycle (creation and deletion)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure Foundry service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- **A pre-created Memory Store** (see below)
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
### Creating a Memory Store
|
||||
|
||||
Memory stores must be created before running this sample. The .NET SDK currently only supports **using** existing memory stores with agents. To create a memory store, use one of these methods:
|
||||
|
||||
**Option 1: Azure Portal**
|
||||
1. Navigate to your Azure AI Foundry project
|
||||
2. Go to the Memory section
|
||||
3. Create a new memory store with your desired settings
|
||||
|
||||
**Option 2: Python SDK**
|
||||
```python
|
||||
from azure.ai.projects import AIProjectClient
|
||||
from azure.ai.projects.models import MemoryStoreDefaultDefinition, MemoryStoreDefaultOptions
|
||||
from azure.identity import DefaultAzureCredential
|
||||
|
||||
project_client = AIProjectClient(
|
||||
endpoint="https://your-endpoint.openai.azure.com/",
|
||||
credential=DefaultAzureCredential()
|
||||
)
|
||||
|
||||
memory_store = await project_client.memory_stores.create(
|
||||
name="my-memory-store",
|
||||
description="Memory store for Agent Framework conversations",
|
||||
definition=MemoryStoreDefaultDefinition(
|
||||
chat_model=os.environ["AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME"],
|
||||
embedding_model=os.environ["AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME"],
|
||||
options=MemoryStoreDefaultOptions(
|
||||
user_profile_enabled=True,
|
||||
chat_summary_enabled=True
|
||||
)
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
$env:AZURE_AI_MEMORY_STORE_NAME="your-memory-store-name" # Required - name of pre-created memory store
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
Navigate to the FoundryAgents sample directory and run:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/GettingStarted/FoundryAgents
|
||||
dotnet run --project .\FoundryAgents_Step26_MemorySearch
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
The sample will:
|
||||
|
||||
1. Create an agent with Memory Search tool configured
|
||||
2. Send a message with personal information ("My name is Alice and I love programming in C#")
|
||||
3. Wait for memory indexing
|
||||
4. Ask the agent to recall the previously shared information
|
||||
5. Display memory search results if available in the response
|
||||
6. Clean up by deleting the agent (note: memory store persists)
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Memory Store Lifecycle**: Memory stores are long-lived resources and are NOT deleted when the agent is deleted. Clean them up separately via Azure Portal or Python SDK.
|
||||
- **Scope**: The `scope` parameter isolates memories per user/context. Use unique identifiers for different users.
|
||||
- **Update Delay**: The `UpdateDelay` parameter controls how quickly new memories are indexed.
|
||||
@@ -58,6 +58,11 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Using plugins](./FoundryAgents_Step13_Plugins/)|This sample demonstrates how to use plugins with a Foundry agent|
|
||||
|[Code interpreter](./FoundryAgents_Step14_CodeInterpreter/)|This sample demonstrates how to use the code interpreter tool with a Foundry agent|
|
||||
|[Computer use](./FoundryAgents_Step15_ComputerUse/)|This sample demonstrates how to use computer use capabilities with a Foundry agent|
|
||||
|[Bing Custom Search](./FoundryAgents_Step21_BingCustomSearch/)|This sample demonstrates how to use Bing Custom Search tool with a Foundry agent|
|
||||
|[SharePoint grounding](./FoundryAgents_Step22_SharePoint/)|This sample demonstrates how to use the SharePoint grounding tool with a Foundry agent|
|
||||
|[Microsoft Fabric](./FoundryAgents_Step23_MicrosoftFabric/)|This sample demonstrates how to use Microsoft Fabric tool with a Foundry agent|
|
||||
|[Web search](./FoundryAgents_Step25_WebSearch/)|This sample demonstrates how to use the Responses API web search tool with a Foundry agent|
|
||||
|[Memory search](./FoundryAgents_Step26_MemorySearch/)|This sample demonstrates how to use memory search tool with a Foundry agent|
|
||||
|[File search](./FoundryAgents_Step18_FileSearch/)|This sample demonstrates how to use the file search tool with a Foundry agent|
|
||||
|[Local MCP](./FoundryAgents_Step27_LocalMCP/)|This sample demonstrates how to use a local MCP client with a Foundry agent|
|
||||
|
||||
|
||||
@@ -18,3 +18,4 @@ of the agent framework.
|
||||
|[Agent With Anthropic](./AgentWithAnthropic/README.md)|Getting started with agents using Anthropic Claude|
|
||||
|[Workflow](./Workflows/README.md)|Getting started with Workflow|
|
||||
|[Model Context Protocol](./ModelContextProtocol/README.md)|Getting started with Model Context Protocol|
|
||||
|[Agent Skills](./AgentSkills/README.md)|Getting started with Agent Skills|
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
|
||||
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
|
||||
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.AzureAI\Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="InvokeMcpTool.yaml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# This workflow demonstrates invoking MCP tools directly from a declarative workflow.
|
||||
# Uses the Foundry MCP server to search AI model details.
|
||||
#
|
||||
# The workflow:
|
||||
# 1. Accepts a model search term as input
|
||||
# 2. Invokes the Foundry MCP tool
|
||||
# 3. Invokes the Microsoft Learn MCP tool
|
||||
# 4. Uses an agent to summarize the results
|
||||
#
|
||||
# Example input:
|
||||
# gpt-4.1
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_mcp_tool
|
||||
actions:
|
||||
|
||||
# Set the search query from user input or use default
|
||||
- kind: SetVariable
|
||||
id: set_search_query
|
||||
variable: Local.SearchQuery
|
||||
value: =System.LastMessage.Text
|
||||
|
||||
# Invoke MCP search tool on Foundry MCP server
|
||||
- kind: InvokeMcpTool
|
||||
id: invoke_foundry_search
|
||||
serverUrl: https://mcp.ai.azure.com
|
||||
serverLabel: azure_mcp_server
|
||||
toolName: model_details_get
|
||||
conversationId: =System.ConversationId
|
||||
arguments:
|
||||
modelName: =Local.SearchQuery
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.FoundrySearchResult
|
||||
|
||||
# Invoke MCP search tool on Microsoft Learn server
|
||||
- kind: InvokeMcpTool
|
||||
id: invoke_docs_search
|
||||
serverUrl: https://learn.microsoft.com/api/mcp
|
||||
serverLabel: microsoft_docs
|
||||
toolName: microsoft_docs_search
|
||||
conversationId: =System.ConversationId
|
||||
arguments:
|
||||
query: =Local.SearchQuery
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.DocsSearchResult
|
||||
|
||||
# Use the search agent to provide a helpful response based on results
|
||||
- kind: InvokeAzureAgent
|
||||
id: summarize_results
|
||||
agent:
|
||||
name: McpSearchAgent
|
||||
conversationId: =System.ConversationId
|
||||
input:
|
||||
messages: =UserMessage("Based on the search results for '" & Local.SearchQuery & "', please provide a helpful summary.")
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.Summary
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates using the InvokeMcpTool action to call MCP (Model Context Protocol)
|
||||
// server tools directly from a declarative workflow. MCP servers expose tools that can be
|
||||
// invoked to perform specific tasks, like searching documentation or executing operations.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shared.Foundry;
|
||||
using Shared.Workflows;
|
||||
|
||||
namespace Demo.Workflows.Declarative.InvokeMcpTool;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates a workflow that uses InvokeMcpTool to call MCP server tools
|
||||
/// directly from the workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The InvokeMcpTool action allows workflows to invoke tools on MCP (Model Context Protocol)
|
||||
/// servers. This enables:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Searching external data sources like documentation</item>
|
||||
/// <item>Executing operations on remote servers</item>
|
||||
/// <item>Integrating with MCP-compatible services</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Azure foundry MCP server to get AI model details.
|
||||
/// When you run the sample, provide an AI model (e.g. gpt-4.1-mini) as input,
|
||||
/// The workflow will use the MCP tools to find relevant information about the model from Microsoft Learn and foundry, then an agent will summarize the results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the README.md file in the parent folder (../README.md) for detailed
|
||||
/// information about the configuration required to run this sample.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class Program
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
// Initialize configuration
|
||||
IConfiguration configuration = Application.InitializeConfig();
|
||||
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
|
||||
|
||||
// Ensure sample agent exists in Foundry
|
||||
await CreateAgentAsync(foundryEndpoint, configuration);
|
||||
|
||||
// Get input from command line or console
|
||||
string workflowInput = Application.GetInput(args);
|
||||
|
||||
// Create the MCP tool handler for invoking MCP server tools.
|
||||
// The HttpClient callback allows configuring authentication per MCP server.
|
||||
// Different MCP servers may require different authentication configurations.
|
||||
// For Production scenarios, consider implementing a more robust HttpClient management strategy to reuse HttpClient instances and manage their lifetimes appropriately.
|
||||
List<HttpClient> createdHttpClients = [];
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
DefaultAzureCredential credential = new();
|
||||
DefaultMcpToolHandler mcpToolHandler = new(
|
||||
httpClientProvider: async (serverUrl, cancellationToken) =>
|
||||
{
|
||||
if (serverUrl.StartsWith("https://mcp.ai.azure.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Acquire token for the Azure MCP server
|
||||
AccessToken token = await credential.GetTokenAsync(
|
||||
new TokenRequestContext(["https://mcp.ai.azure.com/.default"]),
|
||||
cancellationToken);
|
||||
|
||||
// Create HttpClient with Authorization header
|
||||
HttpClient httpClient = new();
|
||||
httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Token);
|
||||
createdHttpClients.Add(httpClient);
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
if (serverUrl.StartsWith("https://learn.microsoft.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Microsoft Learn MCP server does not require authentication
|
||||
HttpClient httpClient = new();
|
||||
createdHttpClients.Add(httpClient);
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
// Return null for unknown servers to use the default HttpClient without auth.
|
||||
return null;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Create the workflow factory with MCP tool provider
|
||||
WorkflowFactory workflowFactory = new("InvokeMcpTool.yaml", foundryEndpoint)
|
||||
{
|
||||
McpToolHandler = mcpToolHandler
|
||||
};
|
||||
|
||||
// Execute the workflow
|
||||
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
|
||||
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up connections and dispose created HttpClients
|
||||
await mcpToolHandler.DisposeAsync();
|
||||
|
||||
foreach (HttpClient httpClient in createdHttpClients)
|
||||
{
|
||||
httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
|
||||
{
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
|
||||
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
agentName: "McpSearchAgent",
|
||||
agentDefinition: DefineSearchAgent(configuration),
|
||||
agentDescription: "Provides information based on search results");
|
||||
}
|
||||
|
||||
private static PromptAgentDefinition DefineSearchAgent(IConfiguration configuration)
|
||||
{
|
||||
return new PromptAgentDefinition(configuration.GetValue(Application.Settings.FoundryModelMini))
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful assistant that answers questions based on search results.
|
||||
Use the information provided in the conversation history to answer questions.
|
||||
If the information is already available in the conversation, use it directly.
|
||||
Be concise and helpful in your responses.
|
||||
"""
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -435,7 +435,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
var partitionKey = BuildPartitionKey(state);
|
||||
|
||||
// Efficient count query
|
||||
var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
|
||||
var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
|
||||
.WithParameter("@conversationId", state.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
@@ -469,7 +469,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
var partitionKey = BuildPartitionKey(state);
|
||||
|
||||
// Batch delete for efficiency
|
||||
var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
|
||||
var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
|
||||
.WithParameter("@conversationId", state.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
|
||||
namespace Microsoft.Agents.AI.FoundryMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Internal extension methods for <see cref="AIProjectClient"/> to provide MemoryStores helper operations.
|
||||
/// </summary>
|
||||
internal static class AIProjectClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a memory store if it doesn't already exist.
|
||||
/// </summary>
|
||||
internal static async Task<bool> CreateMemoryStoreIfNotExistsAsync(
|
||||
this AIProjectClient client,
|
||||
string memoryStoreName,
|
||||
string? description,
|
||||
string chatModel,
|
||||
string embeddingModel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.MemoryStores.GetMemoryStoreAsync(memoryStoreName, cancellationToken).ConfigureAwait(false);
|
||||
return false; // Store already exists
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Store doesn't exist, create it
|
||||
}
|
||||
|
||||
MemoryStoreDefaultDefinition definition = new(chatModel, embeddingModel);
|
||||
await client.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, description, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.FoundryMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Provides JSON serialization utilities for the Foundry Memory provider.
|
||||
/// </summary>
|
||||
internal static class FoundryMemoryJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default JSON serializer options for Foundry Memory operations.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
TypeInfoResolver = FoundryMemoryJsonContext.Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serialization context for Foundry Memory types.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.General,
|
||||
UseStringEnumConverter = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(FoundryMemoryProviderScope))]
|
||||
[JsonSerializable(typeof(FoundryMemoryProvider.State))]
|
||||
internal partial class FoundryMemoryJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,440 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.FoundryMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an Azure AI Foundry Memory backed <see cref="AIContextProvider"/> that persists conversation messages as memories
|
||||
/// and retrieves related memories to augment the agent invocation context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The provider stores user, assistant and system messages as Foundry memories and retrieves relevant memories
|
||||
/// for new invocations using the memory search endpoint. Retrieved memories are injected as user messages
|
||||
/// to the model, prefixed by a configurable context prompt.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryMemoryProvider : AIContextProvider
|
||||
{
|
||||
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
|
||||
|
||||
private readonly ProviderSessionState<State> _sessionState;
|
||||
private readonly string _contextPrompt;
|
||||
private readonly string _memoryStoreName;
|
||||
private readonly int _maxMemories;
|
||||
private readonly int _updateDelay;
|
||||
private readonly bool _enableSensitiveTelemetryData;
|
||||
|
||||
private readonly AIProjectClient _client;
|
||||
private readonly ILogger<FoundryMemoryProvider>? _logger;
|
||||
|
||||
private string? _lastPendingUpdateId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryMemoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="client">The Azure AI Project client configured for your Foundry project.</param>
|
||||
/// <param name="memoryStoreName">The name of the memory store in Azure AI Foundry.</param>
|
||||
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval.</param>
|
||||
/// <param name="options">Provider options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="memoryStoreName"/> is null or whitespace.</exception>
|
||||
public FoundryMemoryProvider(
|
||||
AIProjectClient client,
|
||||
string memoryStoreName,
|
||||
Func<AgentSession?, State> stateInitializer,
|
||||
FoundryMemoryProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
|
||||
{
|
||||
Throw.IfNull(client);
|
||||
Throw.IfNullOrWhitespace(memoryStoreName);
|
||||
|
||||
this._sessionState = new ProviderSessionState<State>(
|
||||
ValidateStateInitializer(Throw.IfNull(stateInitializer)),
|
||||
options?.StateKey ?? this.GetType().Name,
|
||||
FoundryMemoryJsonUtilities.DefaultOptions);
|
||||
|
||||
FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions();
|
||||
|
||||
this._logger = loggerFactory?.CreateLogger<FoundryMemoryProvider>();
|
||||
this._client = client;
|
||||
|
||||
this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt;
|
||||
this._memoryStoreName = memoryStoreName;
|
||||
this._maxMemories = effectiveOptions.MaxMemories;
|
||||
this._updateDelay = effectiveOptions.UpdateDelay;
|
||||
this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string StateKey => this._sessionState.StateKey;
|
||||
|
||||
private static Func<AgentSession?, State> ValidateStateInitializer(Func<AgentSession?, State> stateInitializer) =>
|
||||
session =>
|
||||
{
|
||||
State state = stateInitializer(session);
|
||||
|
||||
if (state is null)
|
||||
{
|
||||
throw new InvalidOperationException("State initializer must return a non-null state.");
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(context);
|
||||
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
FoundryMemoryProviderScope scope = state.Scope;
|
||||
|
||||
List<ResponseItem> messageItems = (context.AIContext.Messages ?? [])
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.Text))
|
||||
.Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!))
|
||||
.ToList();
|
||||
|
||||
if (messageItems.Count == 0)
|
||||
{
|
||||
return new AIContext();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
MemorySearchOptions searchOptions = new(scope.Scope)
|
||||
{
|
||||
ResultOptions = new MemorySearchResultOptions { MaxMemories = this._maxMemories }
|
||||
};
|
||||
|
||||
foreach (ResponseItem item in messageItems)
|
||||
{
|
||||
searchOptions.Items.Add(item);
|
||||
}
|
||||
|
||||
ClientResult<MemoryStoreSearchResponse> result = await this._client.MemoryStores.SearchMemoriesAsync(
|
||||
this._memoryStoreName,
|
||||
searchOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MemoryStoreSearchResponse response = result.Value;
|
||||
|
||||
List<string> memories = response.Memories
|
||||
.Select(m => m.MemoryItem?.Content ?? string.Empty)
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.ToList();
|
||||
|
||||
string? outputMessageText = memories.Count == 0
|
||||
? null
|
||||
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
|
||||
|
||||
if (this._logger?.IsEnabled(LogLevel.Information) is true)
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"FoundryMemoryProvider: Retrieved {Count} memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
|
||||
memories.Count,
|
||||
this._memoryStoreName,
|
||||
this.SanitizeLogData(scope.Scope));
|
||||
|
||||
if (outputMessageText is not null && this._logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
this._logger.LogTrace(
|
||||
"FoundryMemoryProvider: Search Results\nOutput:{MessageText}\nMemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
|
||||
this.SanitizeLogData(outputMessageText),
|
||||
this._memoryStoreName,
|
||||
this.SanitizeLogData(scope.Scope));
|
||||
}
|
||||
}
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, outputMessageText)]
|
||||
};
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (this._logger?.IsEnabled(LogLevel.Error) is true)
|
||||
{
|
||||
this._logger.LogError(
|
||||
ex,
|
||||
"FoundryMemoryProvider: Failed to search for memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
|
||||
this._memoryStoreName,
|
||||
this.SanitizeLogData(scope.Scope));
|
||||
}
|
||||
|
||||
return new AIContext();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
FoundryMemoryProviderScope scope = state.Scope;
|
||||
|
||||
try
|
||||
{
|
||||
List<ResponseItem> messageItems = context.RequestMessages
|
||||
.Concat(context.ResponseMessages ?? [])
|
||||
.Where(m => IsAllowedRole(m.Role) && !string.IsNullOrWhiteSpace(m.Text))
|
||||
.Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!))
|
||||
.ToList();
|
||||
|
||||
if (messageItems.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MemoryUpdateOptions updateOptions = new(scope.Scope)
|
||||
{
|
||||
UpdateDelay = this._updateDelay
|
||||
};
|
||||
|
||||
foreach (ResponseItem item in messageItems)
|
||||
{
|
||||
updateOptions.Items.Add(item);
|
||||
}
|
||||
|
||||
ClientResult<MemoryUpdateResult> result = await this._client.MemoryStores.UpdateMemoriesAsync(
|
||||
this._memoryStoreName,
|
||||
updateOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MemoryUpdateResult response = result.Value;
|
||||
|
||||
if (response.UpdateId is not null)
|
||||
{
|
||||
Interlocked.Exchange(ref this._lastPendingUpdateId, response.UpdateId);
|
||||
}
|
||||
|
||||
if (this._logger?.IsEnabled(LogLevel.Information) is true)
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"FoundryMemoryProvider: Sent {Count} messages to update memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}', UpdateId: '{UpdateId}'.",
|
||||
messageItems.Count,
|
||||
this._memoryStoreName,
|
||||
this.SanitizeLogData(scope.Scope),
|
||||
response.UpdateId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (this._logger?.IsEnabled(LogLevel.Error) is true)
|
||||
{
|
||||
this._logger.LogError(
|
||||
ex,
|
||||
"FoundryMemoryProvider: Failed to send messages to update memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
|
||||
this._memoryStoreName,
|
||||
this.SanitizeLogData(scope.Scope));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures all stored memories for the configured scope are deleted.
|
||||
/// This method handles cases where the scope doesn't exist (no memories stored yet).
|
||||
/// </summary>
|
||||
/// <param name="session">The session containing the scope state to clear memories for.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task EnsureStoredMemoriesDeletedAsync(AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
State state = this._sessionState.GetOrInitializeState(session);
|
||||
FoundryMemoryProviderScope scope = state.Scope;
|
||||
|
||||
try
|
||||
{
|
||||
await this._client.MemoryStores.DeleteScopeAsync(this._memoryStoreName, scope.Scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (this._logger?.IsEnabled(LogLevel.Information) is true)
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"FoundryMemoryProvider: Deleted stored memories for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
|
||||
this._memoryStoreName,
|
||||
this.SanitizeLogData(scope.Scope));
|
||||
}
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Scope doesn't exist (no memories stored yet), nothing to delete
|
||||
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
|
||||
{
|
||||
this._logger.LogDebug(
|
||||
"FoundryMemoryProvider: No memories to delete for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
|
||||
this._memoryStoreName,
|
||||
this.SanitizeLogData(scope.Scope));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the memory store exists, creating it if necessary.
|
||||
/// </summary>
|
||||
/// <param name="chatModel">The deployment name of the chat model for memory processing.</param>
|
||||
/// <param name="embeddingModel">The deployment name of the embedding model for memory search.</param>
|
||||
/// <param name="description">Optional description for the memory store.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task EnsureMemoryStoreCreatedAsync(
|
||||
string chatModel,
|
||||
string embeddingModel,
|
||||
string? description = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
bool created = await this._client.CreateMemoryStoreIfNotExistsAsync(
|
||||
this._memoryStoreName,
|
||||
description,
|
||||
chatModel,
|
||||
embeddingModel,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (created)
|
||||
{
|
||||
if (this._logger?.IsEnabled(LogLevel.Information) is true)
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"FoundryMemoryProvider: Created memory store '{MemoryStoreName}'.",
|
||||
this._memoryStoreName);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
|
||||
{
|
||||
this._logger.LogDebug(
|
||||
"FoundryMemoryProvider: Memory store '{MemoryStoreName}' already exists.",
|
||||
this._memoryStoreName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for all pending memory update operations to complete.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Memory extraction in Azure AI Foundry is asynchronous. This method polls the latest pending update
|
||||
/// and returns when it has completed, failed, or been superseded. Since updates are processed in order,
|
||||
/// completion of the latest update implies all prior updates have also been processed.
|
||||
/// </remarks>
|
||||
/// <param name="pollingInterval">The interval between status checks. Defaults to 5 seconds.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the update operation failed.</exception>
|
||||
public async Task WhenUpdatesCompletedAsync(
|
||||
TimeSpan? pollingInterval = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? updateId = Volatile.Read(ref this._lastPendingUpdateId);
|
||||
if (updateId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan interval = pollingInterval ?? TimeSpan.FromSeconds(5);
|
||||
await this.WaitForUpdateAsync(updateId, interval, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Only clear the pending update ID after successful completion
|
||||
Interlocked.CompareExchange(ref this._lastPendingUpdateId, null, updateId);
|
||||
}
|
||||
|
||||
private async Task WaitForUpdateAsync(string updateId, TimeSpan interval, CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
ClientResult<MemoryUpdateResult> result = await this._client.MemoryStores.GetUpdateResultAsync(
|
||||
this._memoryStoreName,
|
||||
updateId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MemoryUpdateResult response = result.Value;
|
||||
MemoryStoreUpdateStatus status = response.Status;
|
||||
|
||||
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
|
||||
{
|
||||
this._logger.LogDebug(
|
||||
"FoundryMemoryProvider: Update status for '{UpdateId}': {Status}",
|
||||
updateId,
|
||||
status);
|
||||
}
|
||||
|
||||
if (status == MemoryStoreUpdateStatus.Completed || status == MemoryStoreUpdateStatus.Superseded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == MemoryStoreUpdateStatus.Failed)
|
||||
{
|
||||
throw new InvalidOperationException($"Memory update operation '{updateId}' failed: {response.ErrorDetails}");
|
||||
}
|
||||
|
||||
if (status == MemoryStoreUpdateStatus.Queued || status == MemoryStoreUpdateStatus.InProgress)
|
||||
{
|
||||
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown update status '{status}' for update '{updateId}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageResponseItem ToResponseItem(ChatRole role, string text)
|
||||
{
|
||||
if (role == ChatRole.Assistant)
|
||||
{
|
||||
return ResponseItem.CreateAssistantMessageItem(text);
|
||||
}
|
||||
|
||||
if (role == ChatRole.System)
|
||||
{
|
||||
return ResponseItem.CreateSystemMessageItem(text);
|
||||
}
|
||||
|
||||
return ResponseItem.CreateUserMessageItem(text);
|
||||
}
|
||||
|
||||
private static bool IsAllowedRole(ChatRole role) =>
|
||||
role == ChatRole.User || role == ChatRole.Assistant || role == ChatRole.System;
|
||||
|
||||
private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "<redacted>";
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="FoundryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
public sealed class State
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="State"/> class with the specified scope.
|
||||
/// </summary>
|
||||
/// <param name="scope">The scope to use for memory storage and retrieval.</param>
|
||||
[JsonConstructor]
|
||||
public State(FoundryMemoryProviderScope scope)
|
||||
{
|
||||
this.Scope = Throw.IfNull(scope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scope used for memory storage and retrieval.
|
||||
/// </summary>
|
||||
public FoundryMemoryProviderScope Scope { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.FoundryMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring the <see cref="FoundryMemoryProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class FoundryMemoryProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When providing memories to the model, this string is prefixed to the retrieved memories to supply context.
|
||||
/// </summary>
|
||||
/// <value>Defaults to "## Memories\nConsider the following memories when answering user questions:".</value>
|
||||
public string? ContextPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of memories to retrieve during search.
|
||||
/// </summary>
|
||||
/// <value>Defaults to 5.</value>
|
||||
public int MaxMemories { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the delay in seconds before memory updates are processed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Setting to 0 triggers updates immediately without waiting for inactivity.
|
||||
/// Higher values allow the service to batch multiple updates together.
|
||||
/// </remarks>
|
||||
/// <value>Defaults to 0 (immediate).</value>
|
||||
public int UpdateDelay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs.
|
||||
/// </summary>
|
||||
/// <value>Defaults to <see langword="false"/>.</value>
|
||||
public bool EnableSensitiveTelemetryData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the key used to store the provider state in the session's <see cref="AgentSessionStateBag"/>.
|
||||
/// </summary>
|
||||
/// <value>Defaults to the provider's type name.</value>
|
||||
public string? StateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional filter function applied to request messages when building the search text to use when
|
||||
/// searching for relevant memories during <see cref="AIContextProvider.InvokingAsync"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// When <see langword="null"/>, the provider defaults to including only
|
||||
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
|
||||
/// </value>
|
||||
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? SearchInputMessageFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional filter function applied to request messages when determining which messages to
|
||||
/// extract memories from during <see cref="AIContextProvider.InvokedAsync"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// 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; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.FoundryMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Allows scoping of memories for the <see cref="FoundryMemoryProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Azure AI Foundry memories are scoped by a single string identifier that you control.
|
||||
/// Common patterns include using a user ID, team ID, or other unique identifier
|
||||
/// to partition memories across different contexts.
|
||||
/// </remarks>
|
||||
public sealed class FoundryMemoryProviderScope
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryMemoryProviderScope"/> class with the specified scope identifier.
|
||||
/// </summary>
|
||||
/// <param name="scope">The scope identifier used to partition memories. Must not be null or whitespace.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="scope"/> is null or whitespace.</exception>
|
||||
public FoundryMemoryProviderScope(string scope)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(scope);
|
||||
this.Scope = scope;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scope identifier used to partition memories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This value controls how memory is partitioned in the memory store.
|
||||
/// Each unique scope maintains its own isolated collection of memory items.
|
||||
/// For example, use a user ID to ensure each user has their own individual memory.
|
||||
/// </remarks>
|
||||
public string Scope { get; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
<PropertyGroup>
|
||||
<!-- Disable packing until we are ready to release this as a nuget -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework - Azure AI Foundry Memory integration</Title>
|
||||
<Description>Provides Azure AI Foundry Memory integration for Microsoft Agent Framework.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.FoundryMemory.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+142
-3
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using A2A;
|
||||
using A2A.AspNetCore;
|
||||
using Microsoft.Agents.AI;
|
||||
@@ -10,12 +11,14 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.AspNetCore.Builder;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
@@ -33,6 +36,20 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
|
||||
=> endpoints.MapA2A(agentBuilder, path, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
@@ -43,6 +60,21 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
|
||||
=> endpoints.MapA2A(agentName, path, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
@@ -109,6 +141,37 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
|
||||
=> endpoints.MapA2A(agentName, path, agentCard, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, agentCard, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
@@ -144,10 +207,28 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
|
||||
=> endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager);
|
||||
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -160,6 +241,17 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
|
||||
=> endpoints.MapA2A(agent, path, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode)
|
||||
=> endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
@@ -169,13 +261,25 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager)
|
||||
=> endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore);
|
||||
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode);
|
||||
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
|
||||
|
||||
configureTaskManager(taskManager);
|
||||
@@ -198,6 +302,23 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
|
||||
=> endpoints.MapA2A(agent, path, agentCard, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode)
|
||||
=> endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
@@ -213,13 +334,31 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
|
||||
=> endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory);
|
||||
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode);
|
||||
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
|
||||
|
||||
configureTaskManager(taskManager);
|
||||
|
||||
+6
@@ -8,6 +8,12 @@
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Provides JSON serialization options for A2A Hosting APIs to support AOT and trimming.
|
||||
/// </summary>
|
||||
public static class A2AHostingJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for A2A Hosting serialization.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateDefaultOptions()
|
||||
{
|
||||
JsonSerializerOptions options = new(global::A2A.A2AJsonUtilities.DefaultOptions);
|
||||
|
||||
// Chain in the resolvers from both AgentAbstractionsJsonUtilities and the A2A SDK context.
|
||||
// AgentAbstractionsJsonUtilities is first to ensure M.E.AI types (e.g. ResponseContinuationToken)
|
||||
// are handled via its resolver, followed by the A2A SDK resolver for protocol types.
|
||||
options.TypeInfoResolverChain.Clear();
|
||||
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
options.TypeInfoResolverChain.Add(global::A2A.A2AJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Provides context for a custom A2A run mode decision.
|
||||
/// </summary>
|
||||
public sealed class A2ARunDecisionContext
|
||||
{
|
||||
internal A2ARunDecisionContext(MessageSendParams messageSendParams)
|
||||
{
|
||||
this.MessageSendParams = messageSendParams;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parameters of the incoming A2A message that triggered this run.
|
||||
/// </summary>
|
||||
public MessageSendParams MessageSendParams { get; }
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public static class AIAgentExtensions
|
||||
{
|
||||
// Metadata key used to store continuation tokens for long-running background operations
|
||||
// in the AgentTask.Metadata dictionary, persisted by the task store.
|
||||
private const string ContinuationTokenMetadataKey = "__a2a__continuationToken";
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
@@ -21,49 +31,45 @@ public static class AIAgentExtensions
|
||||
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
|
||||
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
|
||||
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
|
||||
/// <param name="runMode">Controls the response behavior of the agent run.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
|
||||
/// <returns>The configured <see cref="TaskManager"/>.</returns>
|
||||
public static ITaskManager MapA2A(
|
||||
this AIAgent agent,
|
||||
ITaskManager? taskManager = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
AgentSessionStore? agentSessionStore = null)
|
||||
AgentSessionStore? agentSessionStore = null,
|
||||
AgentRunMode? runMode = null,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(agent.Name);
|
||||
|
||||
runMode ??= AgentRunMode.DisallowBackground;
|
||||
|
||||
var hostAgent = new AIHostAgent(
|
||||
innerAgent: agent,
|
||||
sessionStore: agentSessionStore ?? new NoopAgentSessionStore());
|
||||
|
||||
taskManager ??= new TaskManager();
|
||||
taskManager.OnMessageReceived += OnMessageReceivedAsync;
|
||||
|
||||
// Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent.
|
||||
JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions;
|
||||
|
||||
// OnMessageReceived handles both message-only and task-based flows.
|
||||
// The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set,
|
||||
// so we consolidate all initial message handling here and return either
|
||||
// an AgentMessage or AgentTask depending on the agent response.
|
||||
// When the agent returns a ContinuationToken (long-running operation), a task is
|
||||
// created for stateful tracking. Otherwise a lightweight AgentMessage is returned.
|
||||
// See https://github.com/a2aproject/a2a-dotnet/issues/275
|
||||
taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct);
|
||||
|
||||
// Task flow for subsequent updates and cancellations
|
||||
taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct);
|
||||
taskManager.OnTaskCancelled += OnTaskCancelledAsync;
|
||||
|
||||
return taskManager;
|
||||
|
||||
async Task<A2AResponse> OnMessageReceivedAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
var options = messageSendParams.Metadata is not { Count: > 0 }
|
||||
? null
|
||||
: new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
|
||||
|
||||
var response = await hostAgent.RunAsync(
|
||||
messageSendParams.ToChatMessages(),
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
var parts = response.Messages.ToParts();
|
||||
return new AgentMessage
|
||||
{
|
||||
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = MessageRole.Agent,
|
||||
Parts = parts,
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -74,15 +80,19 @@ public static class AIAgentExtensions
|
||||
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
|
||||
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
|
||||
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
|
||||
/// <param name="runMode">Controls the response behavior of the agent run.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
|
||||
/// <returns>The configured <see cref="TaskManager"/>.</returns>
|
||||
public static ITaskManager MapA2A(
|
||||
this AIAgent agent,
|
||||
AgentCard agentCard,
|
||||
ITaskManager? taskManager = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
AgentSessionStore? agentSessionStore = null)
|
||||
AgentSessionStore? agentSessionStore = null,
|
||||
AgentRunMode? runMode = null,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore);
|
||||
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions);
|
||||
|
||||
taskManager.OnAgentCardQuery += (context, query) =>
|
||||
{
|
||||
@@ -97,4 +107,203 @@ public static class AIAgentExtensions
|
||||
};
|
||||
return taskManager;
|
||||
}
|
||||
|
||||
private static async Task<A2AResponse> OnMessageReceivedAsync(
|
||||
MessageSendParams messageSendParams,
|
||||
AIHostAgent hostAgent,
|
||||
AgentRunMode runMode,
|
||||
ITaskManager taskManager,
|
||||
JsonSerializerOptions continuationTokenJsonOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// AIAgent does not support resuming from arbitrary prior tasks.
|
||||
// Throw explicitly so the client gets a clear error rather than a response
|
||||
// that silently ignores the referenced task context.
|
||||
// Follow-ups on the *same* task are handled via OnTaskUpdated instead.
|
||||
if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 })
|
||||
{
|
||||
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task.");
|
||||
}
|
||||
|
||||
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Decide whether to run in background based on user preferences and agent capabilities
|
||||
var decisionContext = new A2ARunDecisionContext(messageSendParams);
|
||||
var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var options = messageSendParams.Metadata is not { Count: > 0 }
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
|
||||
|
||||
var response = await hostAgent.RunAsync(
|
||||
messageSendParams.ToChatMessages(),
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
return CreateMessageFromResponse(contextId, response);
|
||||
}
|
||||
|
||||
var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false);
|
||||
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
|
||||
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
|
||||
return agentTask;
|
||||
}
|
||||
|
||||
private static async Task OnTaskUpdatedAsync(
|
||||
AgentTask agentTask,
|
||||
AIHostAgent hostAgent,
|
||||
ITaskManager taskManager,
|
||||
JsonSerializerOptions continuationTokenJsonOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Discard any stale continuation token — the incoming user message supersedes
|
||||
// any previous background operation. AF agents don't support updating existing
|
||||
// background responses (long-running operations); we start a fresh run from the
|
||||
// existing session using the full chat history (which includes the new message).
|
||||
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
|
||||
|
||||
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await hostAgent.RunAsync(
|
||||
ExtractChatMessagesFromTaskHistory(agentTask),
|
||||
session: session,
|
||||
options: new AgentRunOptions { AllowBackgroundResponses = true },
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is not null)
|
||||
{
|
||||
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
|
||||
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await taskManager.UpdateStatusAsync(
|
||||
agentTask.Id,
|
||||
TaskState.Failed,
|
||||
final: true,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken)
|
||||
{
|
||||
// Remove the continuation token from metadata if present.
|
||||
// The task has already been marked as cancelled by the TaskManager.
|
||||
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) =>
|
||||
new()
|
||||
{
|
||||
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = MessageRole.Agent,
|
||||
Parts = response.Messages.ToParts(),
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
// Task outputs should be returned as artifacts rather than messages:
|
||||
// https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts
|
||||
private static Artifact CreateArtifactFromResponse(AgentResponse response) =>
|
||||
new()
|
||||
{
|
||||
ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
Parts = response.Messages.ToParts(),
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static async Task<AgentTask> InitializeTaskAsync(
|
||||
string contextId,
|
||||
AgentMessage originalMessage,
|
||||
ITaskManager taskManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Add the original user message to the task history.
|
||||
// The A2A SDK does this internally when it creates tasks via OnTaskCreated.
|
||||
agentTask.History ??= [];
|
||||
agentTask.History.Add(originalMessage);
|
||||
|
||||
// Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate
|
||||
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return agentTask;
|
||||
}
|
||||
|
||||
private static void StoreContinuationToken(
|
||||
AgentTask agentTask,
|
||||
ResponseContinuationToken token,
|
||||
JsonSerializerOptions continuationTokenJsonOptions)
|
||||
{
|
||||
// Serialize the continuation token into the task's metadata so it survives
|
||||
// across requests and is cleaned up with the task itself.
|
||||
agentTask.Metadata ??= [];
|
||||
agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement(
|
||||
token,
|
||||
continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
}
|
||||
|
||||
private static async Task TransitionToWorkingAsync(
|
||||
string taskId,
|
||||
string contextId,
|
||||
AgentResponse response,
|
||||
ITaskManager taskManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Include any intermediate progress messages from the response as a status message.
|
||||
AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null;
|
||||
await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task CompleteWithArtifactAsync(
|
||||
string taskId,
|
||||
AgentResponse response,
|
||||
ITaskManager taskManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var artifact = CreateArtifactFromResponse(response);
|
||||
await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false);
|
||||
await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask agentTask)
|
||||
{
|
||||
if (agentTask.History is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var chatMessages = new List<ChatMessage>(agentTask.History.Count);
|
||||
foreach (var message in agentTask.History)
|
||||
{
|
||||
chatMessages.Add(message.ToChatMessage());
|
||||
}
|
||||
|
||||
return chatMessages;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how the A2A hosting layer determines whether to run <see cref="AIAgent"/> in background or not.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public sealed class AgentRunMode : IEquatable<AgentRunMode>
|
||||
{
|
||||
private const string MessageValue = "message";
|
||||
private const string TaskValue = "task";
|
||||
private const string DynamicValue = "dynamic";
|
||||
|
||||
private readonly string _value;
|
||||
private readonly Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? _runInBackground;
|
||||
|
||||
private AgentRunMode(string value, Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? runInBackground = null)
|
||||
{
|
||||
this._value = value;
|
||||
this._runInBackground = runInBackground;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dissallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
|
||||
/// In the A2A protocol terminology will make responses be returned as <c>AgentMessage</c>.
|
||||
/// </summary>
|
||||
public static AgentRunMode DisallowBackground => new(MessageValue);
|
||||
|
||||
/// <summary>
|
||||
/// Allows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>true</c>.
|
||||
/// In the A2A protocol terminology will make responses be returned as <c>AgentTask</c> if the agent supports background responses, and as <c>AgentMessage</c> otherwise.
|
||||
/// </summary>
|
||||
public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue);
|
||||
|
||||
/// <summary>
|
||||
/// The agent run mode is decided by the supplied <paramref name="runInBackground"/> delegate.
|
||||
/// The delegate receives an <see cref="A2ARunDecisionContext"/> with the incoming
|
||||
/// message and returns a boolean specifying whether to run the agent in background mode.
|
||||
/// <see langword="true"/> indicates that the agent should run in background mode and return an
|
||||
/// <c>AgentTask</c> if the agent supports background mode; otherwise, it returns an <c>AgentMessage</c>
|
||||
/// if the mode is not supported. <see langword="false"/> indicates that the agent should run in
|
||||
/// non-background mode and return an <c>AgentMessage</c>.
|
||||
/// </summary>
|
||||
/// <param name="runInBackground">
|
||||
/// An async delegate that decides whether the response should be wrapped in an <c>AgentTask</c>.
|
||||
/// </param>
|
||||
public static AgentRunMode AllowBackgroundWhen(Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>> runInBackground)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runInBackground);
|
||||
return new(DynamicValue, runInBackground);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the agent response should be returned as an <c>AgentTask</c>.
|
||||
/// </summary>
|
||||
internal ValueTask<bool> ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
if (string.Equals(this._value, TaskValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
// Dynamic: delegate to custom callback.
|
||||
if (this._runInBackground is not null)
|
||||
{
|
||||
return this._runInBackground(context, cancellationToken);
|
||||
}
|
||||
|
||||
// No delegate provided — fall back to "message" behavior.
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(AgentRunMode? other) =>
|
||||
other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => this._value;
|
||||
|
||||
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are equal.</summary>
|
||||
public static bool operator ==(AgentRunMode? left, AgentRunMode? right) =>
|
||||
left?.Equals(right) ?? right is null;
|
||||
|
||||
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are not equal.</summary>
|
||||
public static bool operator !=(AgentRunMode? left, AgentRunMode? right) =>
|
||||
!(left == right);
|
||||
}
|
||||
+1
-2
@@ -2,7 +2,6 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
@@ -37,7 +36,7 @@ internal static class AdditionalPropertiesDictionaryExtensions
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
|
||||
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
|
||||
}
|
||||
|
||||
return metadata;
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
@@ -92,4 +92,23 @@ public static class OpenAIResponseClientExtensions
|
||||
|
||||
return new ChatClientAgent(chatClient, options, loggerFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an <see cref="IChatClient"/> for use with this <see cref="ResponsesClient"/> that does not store responses for later retrieval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This corresponds to setting the "store" property in the JSON representation to false.
|
||||
/// </remarks>
|
||||
/// <param name="responseClient">The client.</param>
|
||||
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ResponsesClient"/> that does not store responses for later retrieval.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="responseClient"/> is <see langword="null"/>.</exception>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static IChatClient AsIChatClientWithStoredOutputDisabled(this ResponsesClient responseClient)
|
||||
{
|
||||
return Throw.IfNull(responseClient)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.ConfigureOptions(x => x.RawRepresentationFactory = _ => new CreateResponseOptions() { StoredOutputEnabled = false })
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ internal sealed class PurviewWrapper : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.DownloadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
if (shouldBlockResponse)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
@@ -186,7 +186,7 @@ internal sealed class PurviewWrapper : IDisposable
|
||||
sessionIdResponse = sessionId;
|
||||
}
|
||||
}
|
||||
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.DownloadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (shouldBlockResponse)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IMcpToolHandler"/> using the MCP C# SDK.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This provider supports per-server authentication via the <c>httpClientProvider</c> callback.
|
||||
/// The callback allows different MCP servers to use different authentication configurations by returning
|
||||
/// a pre-configured <see cref="HttpClient"/> for each server.
|
||||
/// </remarks>
|
||||
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
{
|
||||
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Dictionary<string, McpClient> _clients = [];
|
||||
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
|
||||
private readonly SemaphoreSlim _clientLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultMcpToolHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpClientProvider">
|
||||
/// An optional callback that provides an <see cref="HttpClient"/> for each MCP server.
|
||||
/// The callback receives (serverUrl, cancellationToken) and should return an HttpClient
|
||||
/// configured with any required authentication. Return <see langword="null"/> to use a default HttpClient with no auth.
|
||||
/// </param>
|
||||
public DefaultMcpToolHandler(Func<string, CancellationToken, Task<HttpClient?>>? httpClientProvider = null)
|
||||
{
|
||||
this._httpClientProvider = httpClientProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<McpServerToolResultContent> InvokeToolAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
string toolName,
|
||||
IDictionary<string, object?>? arguments,
|
||||
IDictionary<string, string>? headers,
|
||||
string? connectionName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert IDictionary to IReadOnlyDictionary for CallToolAsync
|
||||
IReadOnlyDictionary<string, object?>? readOnlyArguments = arguments is null
|
||||
? null
|
||||
: arguments as IReadOnlyDictionary<string, object?> ?? new Dictionary<string, object?>(arguments);
|
||||
|
||||
CallToolResult result = await client.CallToolAsync(
|
||||
toolName,
|
||||
readOnlyArguments,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Map MCP content blocks to MEAI AIContent types
|
||||
PopulateResultContent(resultContent, result);
|
||||
|
||||
return resultContent;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await this._clientLock.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
foreach (McpClient client in this._clients.Values)
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._clients.Clear();
|
||||
|
||||
// Dispose only HttpClients that the handler created (not user-provided ones)
|
||||
foreach (HttpClient httpClient in this._ownedHttpClients.Values)
|
||||
{
|
||||
httpClient.Dispose();
|
||||
}
|
||||
|
||||
this._ownedHttpClients.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._clientLock.Release();
|
||||
}
|
||||
|
||||
this._clientLock.Dispose();
|
||||
}
|
||||
|
||||
private async Task<McpClient> GetOrCreateClientAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
IDictionary<string, string>? headers,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
|
||||
string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
|
||||
|
||||
await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (this._clients.TryGetValue(clientCacheKey, out McpClient? existingClient))
|
||||
{
|
||||
return existingClient;
|
||||
}
|
||||
|
||||
McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
|
||||
this._clients[clientCacheKey] = newClient;
|
||||
return newClient;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._clientLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<McpClient> CreateClientAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
IDictionary<string, string>? headers,
|
||||
string httpClientCacheKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get or create HttpClient (Can be shared across McpClients for the same server)
|
||||
HttpClient? httpClient = null;
|
||||
|
||||
if (this._httpClientProvider is not null)
|
||||
{
|
||||
httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
|
||||
{
|
||||
httpClient = new HttpClient();
|
||||
this._ownedHttpClients[httpClientCacheKey] = httpClient;
|
||||
}
|
||||
|
||||
HttpClientTransportOptions transportOptions = new()
|
||||
{
|
||||
Endpoint = new Uri(serverUrl),
|
||||
Name = serverLabel ?? "McpClient",
|
||||
AdditionalHeaders = headers,
|
||||
TransportMode = HttpTransportMode.AutoDetect
|
||||
};
|
||||
|
||||
HttpClientTransport transport = new(transportOptions, httpClient);
|
||||
|
||||
return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ComputeHeadersHash(IDictionary<string, string>? headers)
|
||||
{
|
||||
if (headers is null || headers.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Build a deterministic, sorted representation of the headers
|
||||
// Within a single process lifetime, the hashcodes are consistent.
|
||||
// This will ensure that the same set of headers always produces the same hash, regardless of order.
|
||||
SortedDictionary<string, string> sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant()));
|
||||
int hashCode = 17;
|
||||
foreach (KeyValuePair<string, string> kvp in sorted)
|
||||
{
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
|
||||
}
|
||||
|
||||
return hashCode.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
|
||||
{
|
||||
// Ensure Output list is initialized
|
||||
resultContent.Output ??= [];
|
||||
|
||||
if (result.IsError == true)
|
||||
{
|
||||
// Collect error text from content blocks
|
||||
string? errorText = null;
|
||||
if (result.Content is not null)
|
||||
{
|
||||
foreach (ContentBlock block in result.Content)
|
||||
{
|
||||
if (block is TextContentBlock textBlock)
|
||||
{
|
||||
errorText = errorText is null ? textBlock.Text : $"{errorText}\n{textBlock.Text}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultContent.Output.Add(new TextContent($"Error: {errorText ?? "Unknown error from MCP Server call"}"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Content is null || result.Content.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Map each MCP content block to an MEAI AIContent type
|
||||
foreach (ContentBlock block in result.Content)
|
||||
{
|
||||
AIContent content = ConvertContentBlock(block);
|
||||
if (content is not null)
|
||||
{
|
||||
resultContent.Output.Add(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AIContent ConvertContentBlock(ContentBlock block)
|
||||
{
|
||||
return block switch
|
||||
{
|
||||
TextContentBlock text => new TextContent(text.Text),
|
||||
ImageContentBlock image => CreateDataContentFromBase64(image.Data, image.MimeType ?? "image/*"),
|
||||
AudioContentBlock audio => CreateDataContentFromBase64(audio.Data, audio.MimeType ?? "audio/*"),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
private static DataContent CreateDataContentFromBase64(string? base64Data, string mediaType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64Data))
|
||||
{
|
||||
return new DataContent($"data:{mediaType};base64,", mediaType);
|
||||
}
|
||||
|
||||
// If it's already a data URI, use it directly
|
||||
if (base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new DataContent(base64Data, mediaType);
|
||||
}
|
||||
|
||||
// Otherwise, construct a data URI from the base64 data
|
||||
return new DataContent($"data:{mediaType};base64,{base64Data}", mediaType);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Declarative Workflows MCP</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for MCP (Model Context Protocol) server integration in declarative workflows.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -20,6 +20,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
|
||||
/// </summary>
|
||||
public ResponseAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MCP tool handler for invoking MCP tools within workflows.
|
||||
/// If not set, MCP tool invocations will fail with an appropriate error message.
|
||||
/// </summary>
|
||||
public IMcpToolHandler? McpToolHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Defines the configuration settings for the workflow.
|
||||
/// </summary>
|
||||
|
||||
+46
-2
@@ -42,6 +42,40 @@ internal static class JsonDocumentExtensions
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a VariableType.List with schema inferred from the first object element in the array.
|
||||
/// </summary>
|
||||
public static VariableType GetListTypeFromJson(this JsonElement arrayElement)
|
||||
{
|
||||
// Find the first object element to infer schema
|
||||
foreach (JsonElement element in arrayElement.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Build schema from the object's properties
|
||||
List<(string Key, VariableType Type)> fields = [];
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
VariableType fieldType = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => typeof(string),
|
||||
JsonValueKind.Number => typeof(decimal),
|
||||
JsonValueKind.True or JsonValueKind.False => typeof(bool),
|
||||
JsonValueKind.Object => VariableType.RecordType,
|
||||
JsonValueKind.Array => VariableType.ListType,
|
||||
_ => typeof(string),
|
||||
};
|
||||
fields.Add((property.Name, fieldType));
|
||||
}
|
||||
|
||||
return VariableType.List(fields);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for arrays of primitives or empty arrays
|
||||
return VariableType.ListType;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ParseRecord(this JsonElement currentElement, VariableType targetType)
|
||||
{
|
||||
IEnumerable<KeyValuePair<string, object?>> keyValuePairs =
|
||||
@@ -111,11 +145,14 @@ internal static class JsonDocumentExtensions
|
||||
VariableType? currentType =
|
||||
element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => VariableType.Record(targetType.Schema?.Select(kvp => (kvp.Key, kvp.Value)) ?? []),
|
||||
JsonValueKind.Object => targetType.HasSchema
|
||||
? VariableType.Record(targetType.Schema!.Select(kvp => (kvp.Key, kvp.Value)))
|
||||
: VariableType.RecordType,
|
||||
JsonValueKind.String => typeof(string),
|
||||
JsonValueKind.True => typeof(bool),
|
||||
JsonValueKind.False => typeof(bool),
|
||||
JsonValueKind.Number => typeof(decimal),
|
||||
JsonValueKind.Array => (VariableType)VariableType.ListType, // Add support for nested arrays
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -283,9 +320,16 @@ internal static class JsonDocumentExtensions
|
||||
|
||||
private static bool TryParseList(JsonElement propertyElement, VariableType? targetType, out object? value)
|
||||
{
|
||||
// Handle empty arrays without needing to determine element type
|
||||
if (propertyElement.GetArrayLength() == 0)
|
||||
{
|
||||
value = new List<object?>();
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
value = ParseTable(propertyElement, targetType ?? VariableType.ListType);
|
||||
value = ParseTable(propertyElement, targetType ?? GetListTypeFromJson(propertyElement));
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for invoking MCP tools within declarative workflows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface allows the MCP tool invocation to be abstracted, enabling
|
||||
/// different implementations for local development, hosted workflows, and testing scenarios.
|
||||
/// </remarks>
|
||||
public interface IMcpToolHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes an MCP tool on the specified server.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">The URL of the MCP server.</param>
|
||||
/// <param name="serverLabel">An optional label identifying the server connection.</param>
|
||||
/// <param name="toolName">The name of the tool to invoke.</param>
|
||||
/// <param name="arguments">Optional arguments to pass to the tool.</param>
|
||||
/// <param name="headers">Optional headers to include in the request.</param>
|
||||
/// <param name="connectionName">An optional connection name for managed connections.</param>
|
||||
/// <param name="cancellationToken">A token to observe cancellation.</param>
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation. The result contains a <see cref="McpServerToolResultContent"/>
|
||||
/// with the tool invocation output.
|
||||
/// </returns>
|
||||
Task<McpServerToolResultContent> InvokeToolAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
string toolName,
|
||||
IDictionary<string, object?>? arguments,
|
||||
IDictionary<string, string>? headers,
|
||||
string? connectionName,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
+36
@@ -493,6 +493,42 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
|
||||
this.ContinueWith(new SendActivityExecutor(item, this._workflowState));
|
||||
}
|
||||
|
||||
protected override void Visit(InvokeMcpTool item)
|
||||
{
|
||||
this.Trace(item);
|
||||
|
||||
// Verify MCP handler is configured
|
||||
if (this._workflowOptions.McpToolHandler is null)
|
||||
{
|
||||
throw new DeclarativeModelException("MCP tool handler not configured. Set McpToolHandler in DeclarativeWorkflowOptions to use InvokeMcpTool actions.");
|
||||
}
|
||||
|
||||
// Entry point to invoke MCP tool - may yield for approval
|
||||
InvokeMcpToolExecutor action = new(item, this._workflowOptions.McpToolHandler, this._workflowOptions.AgentProvider, this._workflowState);
|
||||
this.ContinueWith(action);
|
||||
|
||||
// Transition to post action if no external input is required (no approval needed)
|
||||
string postId = Steps.Post(action.Id);
|
||||
this._workflowModel.AddLink(action.Id, postId, InvokeMcpToolExecutor.RequiresNothing);
|
||||
|
||||
// If approval is required, define request-port for approval flow
|
||||
string externalInputPortId = InvokeMcpToolExecutor.Steps.ExternalInput(action.Id);
|
||||
RequestPortAction externalInputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(externalInputPortId));
|
||||
this._workflowModel.AddNode(externalInputPort, action.ParentId);
|
||||
this._workflowModel.AddLink(action.Id, externalInputPortId, InvokeMcpToolExecutor.RequiresInput);
|
||||
|
||||
// Capture response when external input is received
|
||||
string resumeId = InvokeMcpToolExecutor.Steps.Resume(action.Id);
|
||||
this._workflowModel.AddNode(new DelegateActionExecutor<ExternalInputResponse>(resumeId, this._workflowState, action.CaptureResponseAsync), action.ParentId);
|
||||
this._workflowModel.AddLink(externalInputPortId, resumeId);
|
||||
|
||||
// After resume, transition to post action
|
||||
this._workflowModel.AddLink(resumeId, postId);
|
||||
|
||||
// Define post action (completion)
|
||||
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
|
||||
}
|
||||
|
||||
#region Not supported
|
||||
|
||||
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
|
||||
|
||||
+2
@@ -365,6 +365,8 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
|
||||
|
||||
#region Not supported
|
||||
|
||||
protected override void Visit(InvokeMcpTool item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(InvokeFunctionTool item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
|
||||
|
||||
+1
-35
@@ -204,7 +204,7 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
object? parsedValue = jsonDocument.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
|
||||
JsonValueKind.Array => jsonDocument.ParseList(CreateListTypeFromJson(jsonDocument.RootElement)),
|
||||
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
|
||||
JsonValueKind.String => jsonDocument.RootElement.GetString(),
|
||||
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
@@ -224,40 +224,6 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a VariableType.List with schema inferred from the first object element in the array.
|
||||
/// </summary>
|
||||
private static VariableType CreateListTypeFromJson(JsonElement arrayElement)
|
||||
{
|
||||
// Find the first object element to infer schema
|
||||
foreach (JsonElement element in arrayElement.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Build schema from the object's properties
|
||||
List<(string Key, VariableType Type)> fields = [];
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
VariableType fieldType = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => typeof(string),
|
||||
JsonValueKind.Number => typeof(decimal),
|
||||
JsonValueKind.True or JsonValueKind.False => typeof(bool),
|
||||
JsonValueKind.Object => VariableType.RecordType,
|
||||
JsonValueKind.Array => VariableType.ListType,
|
||||
_ => typeof(string),
|
||||
};
|
||||
fields.Add((property.Name, fieldType));
|
||||
}
|
||||
|
||||
return VariableType.List(fields);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for arrays of primitives or empty arrays
|
||||
return VariableType.ListType;
|
||||
}
|
||||
|
||||
private string GetFunctionName() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Executor for the <see cref="InvokeMcpTool"/> action.
|
||||
/// This executor invokes MCP tools on remote servers and handles approval flows.
|
||||
/// </summary>
|
||||
internal sealed class InvokeMcpToolExecutor(
|
||||
InvokeMcpTool model,
|
||||
IMcpToolHandler mcpToolHandler,
|
||||
ResponseAgentProvider agentProvider,
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
|
||||
{
|
||||
/// <summary>
|
||||
/// Step identifiers for the MCP tool invocation workflow.
|
||||
/// </summary>
|
||||
public static class Steps
|
||||
{
|
||||
/// <summary>
|
||||
/// Step for waiting for external input (approval or direct response).
|
||||
/// </summary>
|
||||
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
|
||||
|
||||
/// <summary>
|
||||
/// Step for resuming after receiving external input.
|
||||
/// </summary>
|
||||
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the message indicates external input is required.
|
||||
/// </summary>
|
||||
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the message indicates no external input is required.
|
||||
/// </summary>
|
||||
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool EmitResultEvent => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool IsDiscreteAction => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
[SendsMessage(typeof(ExternalInputRequest))]
|
||||
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string serverUrl = this.GetServerUrl();
|
||||
string? serverLabel = this.GetServerLabel();
|
||||
string toolName = this.GetToolName();
|
||||
bool requireApproval = this.GetRequireApproval();
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
Dictionary<string, string>? headers = this.GetHeaders();
|
||||
string? connectionName = this.GetConnectionName();
|
||||
|
||||
if (requireApproval)
|
||||
{
|
||||
// Create tool call content for approval request
|
||||
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
|
||||
{
|
||||
Arguments = arguments
|
||||
};
|
||||
|
||||
if (headers != null)
|
||||
{
|
||||
toolCall.AdditionalProperties ??= [];
|
||||
toolCall.AdditionalProperties.Add(headers);
|
||||
}
|
||||
|
||||
McpServerToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
|
||||
|
||||
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
|
||||
AgentResponse agentResponse = new([requestMessage]);
|
||||
|
||||
// Yield to the caller for approval
|
||||
ExternalInputRequest inputRequest = new(agentResponse);
|
||||
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
// No approval required - invoke the tool directly
|
||||
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
||||
serverUrl,
|
||||
serverLabel,
|
||||
toolName,
|
||||
arguments,
|
||||
headers,
|
||||
connectionName,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Signal completion so the workflow routes via RequiresNothing
|
||||
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the external input response and processes the MCP tool result.
|
||||
/// </summary>
|
||||
/// <param name="context">The workflow context.</param>
|
||||
/// <param name="response">The external input response.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask CaptureResponseAsync(
|
||||
IWorkflowContext context,
|
||||
ExternalInputResponse response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check for approval response
|
||||
McpServerToolApprovalResponseContent? approvalResponse = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<McpServerToolApprovalResponseContent>()
|
||||
.FirstOrDefault(r => r.Id == this.Id);
|
||||
|
||||
if (approvalResponse?.Approved != true)
|
||||
{
|
||||
// Tool call was rejected
|
||||
await this.AssignErrorAsync(context, "MCP tool invocation was not approved by user.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved - now invoke the tool
|
||||
string serverUrl = this.GetServerUrl();
|
||||
string? serverLabel = this.GetServerLabel();
|
||||
string toolName = this.GetToolName();
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
Dictionary<string, string>? headers = this.GetHeaders();
|
||||
string? connectionName = this.GetConnectionName();
|
||||
|
||||
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
||||
serverUrl,
|
||||
serverLabel,
|
||||
toolName,
|
||||
arguments,
|
||||
headers,
|
||||
connectionName,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the MCP tool invocation by raising the completion event.
|
||||
/// </summary>
|
||||
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
|
||||
{
|
||||
bool autoSend = this.GetAutoSendValue();
|
||||
string? conversationId = this.GetConversationId();
|
||||
|
||||
await this.AssignResultAsync(context, resultContent).ConfigureAwait(false);
|
||||
ChatMessage resultMessage = new(ChatRole.Tool, resultContent.Output);
|
||||
|
||||
// Store messages if output path is configured
|
||||
if (this.Model.Output?.Messages is not null)
|
||||
{
|
||||
await this.AssignAsync(this.Model.Output.Messages?.Path, resultMessage.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Auto-send the result if configured
|
||||
if (autoSend)
|
||||
{
|
||||
AgentResponse resultResponse = new([resultMessage]);
|
||||
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Add messages to conversation if conversationId is provided
|
||||
if (conversationId is not null)
|
||||
{
|
||||
ChatMessage assistantMessage = new(ChatRole.Assistant, resultContent.Output);
|
||||
await agentProvider.CreateMessageAsync(conversationId, assistantMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult)
|
||||
{
|
||||
if (this.Model.Output?.Result is null || toolResult.Output is null || toolResult.Output.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<object?> parsedResults = [];
|
||||
foreach (AIContent resultContent in toolResult.Output)
|
||||
{
|
||||
object? resultValue = resultContent switch
|
||||
{
|
||||
TextContent text => text.Text,
|
||||
DataContent data => data.Uri,
|
||||
_ => resultContent.ToString(),
|
||||
};
|
||||
|
||||
// Convert JsonElement to its raw JSON string for processing
|
||||
if (resultValue is JsonElement jsonElement)
|
||||
{
|
||||
resultValue = jsonElement.GetRawText();
|
||||
}
|
||||
|
||||
// Attempt to parse as JSON if it's a string (or was converted from JsonElement)
|
||||
if (resultValue is string jsonString)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
|
||||
|
||||
// Handle different JSON value kinds
|
||||
object? parsedValue = jsonDocument.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
|
||||
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
|
||||
JsonValueKind.String => jsonDocument.RootElement.GetString(),
|
||||
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => jsonString,
|
||||
};
|
||||
|
||||
parsedResults.Add(parsedValue);
|
||||
continue;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not a valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
parsedResults.Add(resultValue);
|
||||
}
|
||||
|
||||
await this.AssignAsync(this.Model.Output.Result?.Path, parsedResults.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask AssignErrorAsync(IWorkflowContext context, string errorMessage)
|
||||
{
|
||||
// Store error in result if configured (as a simple string)
|
||||
if (this.Model.Output?.Result is not null)
|
||||
{
|
||||
await this.AssignAsync(this.Model.Output.Result?.Path, $"Error: {errorMessage}".ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetServerUrl() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
this.Model.ServerUrl,
|
||||
$"{nameof(this.Model)}.{nameof(this.Model.ServerUrl)}")).Value;
|
||||
|
||||
private string? GetServerLabel()
|
||||
{
|
||||
if (this.Model.ServerLabel is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string value = this.Evaluator.GetValue(this.Model.ServerLabel).Value;
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private string GetToolName() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
this.Model.ToolName,
|
||||
$"{nameof(this.Model)}.{nameof(this.Model.ToolName)}")).Value;
|
||||
|
||||
private string? GetConversationId()
|
||||
{
|
||||
if (this.Model.ConversationId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string value = this.Evaluator.GetValue(this.Model.ConversationId).Value;
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private bool GetRequireApproval()
|
||||
{
|
||||
if (this.Model.RequireApproval is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.RequireApproval).Value;
|
||||
}
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
}
|
||||
|
||||
private string? GetConnectionName()
|
||||
{
|
||||
if (this.Model.Connection?.Name is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string value = this.Evaluator.GetValue(this.Model.Connection.Name).Value;
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private Dictionary<string, object?>? GetArguments()
|
||||
{
|
||||
if (this.Model.Arguments is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, object?> result = [];
|
||||
foreach (KeyValuePair<string, ValueExpression> argument in this.Model.Arguments)
|
||||
{
|
||||
result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Dictionary<string, string>? GetHeaders()
|
||||
{
|
||||
if (this.Model.Headers is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> result = [];
|
||||
foreach (KeyValuePair<string, StringExpression> header in this.Model.Headers)
|
||||
{
|
||||
string value = this.Evaluator.GetValue(header.Value).Value;
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
result[header.Key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
private int _isDisposed;
|
||||
|
||||
private readonly ISuperStepRunner _stepRunner;
|
||||
private Activity? _sessionActivity;
|
||||
|
||||
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellationToken = default) => new(this.RunStatus);
|
||||
|
||||
@@ -30,7 +31,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// No-op for lockstep execution
|
||||
// Save and restore Activity.Current so the long-lived session activity
|
||||
// doesn't leak into caller code via AsyncLocal.
|
||||
Activity? previousActivity = Activity.Current;
|
||||
|
||||
this._sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
|
||||
this._sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
|
||||
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
this._sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted));
|
||||
|
||||
Activity.Current = previousActivity;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
@@ -44,19 +54,23 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
}
|
||||
#endif
|
||||
|
||||
CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken);
|
||||
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken);
|
||||
|
||||
ConcurrentQueue<WorkflowEvent> eventSink = [];
|
||||
|
||||
this._stepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync;
|
||||
|
||||
using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
|
||||
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
// Re-establish session as parent so the run activity nests correctly.
|
||||
Activity.Current = this._sessionActivity;
|
||||
|
||||
// Not 'using' — must dispose explicitly in finally for deterministic export.
|
||||
Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
|
||||
runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
|
||||
try
|
||||
{
|
||||
this.RunStatus = RunStatus.Running;
|
||||
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
|
||||
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
|
||||
|
||||
do
|
||||
{
|
||||
@@ -65,7 +79,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
{
|
||||
// Because we may be yielding out of this function, we need to ensure that the Activity.Current
|
||||
// is set to our activity for the duration of this loop iteration.
|
||||
Activity.Current = activity;
|
||||
Activity.Current = runActivity;
|
||||
|
||||
// Drain SuperSteps while there are steps to run
|
||||
try
|
||||
@@ -75,13 +89,13 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex) when (activity is not null)
|
||||
catch (Exception ex) when (runActivity is not null)
|
||||
{
|
||||
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
|
||||
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
|
||||
{ Tags.ErrorType, ex.GetType().FullName },
|
||||
{ Tags.BuildErrorMessage, ex.Message },
|
||||
{ Tags.ErrorMessage, ex.Message },
|
||||
}));
|
||||
activity.CaptureException(ex);
|
||||
runActivity.CaptureException(ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -129,12 +143,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
}
|
||||
} while (!ShouldBreak());
|
||||
|
||||
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle;
|
||||
this._stepRunner.OutgoingEvents.EventRaised -= OnWorkflowEventAsync;
|
||||
|
||||
// Explicitly dispose the Activity so Activity.Stop fires deterministically,
|
||||
// regardless of how the async iterator enumerator is disposed.
|
||||
runActivity?.Dispose();
|
||||
}
|
||||
|
||||
ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e)
|
||||
@@ -172,6 +190,14 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
{
|
||||
this._stopCancellation.Cancel();
|
||||
|
||||
// Stop the session activity
|
||||
if (this._sessionActivity is not null)
|
||||
{
|
||||
this._sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted));
|
||||
this._sessionActivity.Dispose();
|
||||
this._sessionActivity = null;
|
||||
}
|
||||
|
||||
this._stopCancellation.Dispose();
|
||||
this._inputWaiter.Dispose();
|
||||
}
|
||||
|
||||
@@ -55,13 +55,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
private async Task RunLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using CancellationTokenSource errorSource = new();
|
||||
CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken);
|
||||
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken);
|
||||
|
||||
// Subscribe to events - they will flow directly to the channel as they're raised
|
||||
this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync;
|
||||
|
||||
using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
|
||||
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
// Start the session-level activity that spans the entire run loop lifetime.
|
||||
// Individual run-stage activities are nested within this session activity.
|
||||
Activity? sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
|
||||
sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
|
||||
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
|
||||
Activity? runActivity = null;
|
||||
|
||||
sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted));
|
||||
|
||||
try
|
||||
{
|
||||
@@ -70,10 +77,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
this._runStatus = RunStatus.Running;
|
||||
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
|
||||
|
||||
while (!linkedSource.Token.IsCancellationRequested)
|
||||
{
|
||||
// Start a new run-stage activity for this input→processing→halt cycle
|
||||
runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
|
||||
runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
|
||||
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
|
||||
|
||||
// Run all available supersteps continuously
|
||||
// Events are streamed out in real-time as they happen via the event handler
|
||||
while (this._stepRunner.HasUnprocessedMessages && !linkedSource.Token.IsCancellationRequested)
|
||||
@@ -93,6 +105,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
RunStatus capturedStatus = this._runStatus;
|
||||
await this._eventChannel.Writer.WriteAsync(new InternalHaltSignal(currentEpoch, capturedStatus), linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
// Close the run-stage activity when processing halts.
|
||||
// A new run activity will be created when the next input arrives.
|
||||
if (runActivity is not null)
|
||||
{
|
||||
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
runActivity.Dispose();
|
||||
runActivity = null;
|
||||
}
|
||||
|
||||
// Wait for next input from the consumer
|
||||
// Works for both Idle (no work) and PendingRequests (waiting for responses)
|
||||
await this._inputWaiter.WaitForInputAsync(TimeSpan.FromSeconds(1), linkedSource.Token).ConfigureAwait(false);
|
||||
@@ -107,14 +128,26 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (activity != null)
|
||||
// Record error on the run-stage activity if one is active
|
||||
if (runActivity is not null)
|
||||
{
|
||||
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
|
||||
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
|
||||
{ Tags.ErrorType, ex.GetType().FullName },
|
||||
{ Tags.BuildErrorMessage, ex.Message },
|
||||
{ Tags.ErrorMessage, ex.Message },
|
||||
}));
|
||||
activity.CaptureException(ex);
|
||||
runActivity.CaptureException(ex);
|
||||
}
|
||||
|
||||
// Record error on the session activity
|
||||
if (sessionActivity is not null)
|
||||
{
|
||||
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionError, tags: new() {
|
||||
{ Tags.ErrorType, ex.GetType().FullName },
|
||||
{ Tags.ErrorMessage, ex.Message },
|
||||
}));
|
||||
sessionActivity.CaptureException(ex);
|
||||
}
|
||||
|
||||
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
@@ -124,7 +157,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
|
||||
// Mark as ended when run loop exits
|
||||
this._runStatus = RunStatus.Ended;
|
||||
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
|
||||
// Stop the run-stage activity if not already stopped (e.g. on cancellation or error)
|
||||
if (runActivity is not null)
|
||||
{
|
||||
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
runActivity.Dispose();
|
||||
}
|
||||
|
||||
// Stop the session activity — the session always ends when the run loop exits
|
||||
if (sessionActivity is not null)
|
||||
{
|
||||
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted));
|
||||
sessionActivity.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e)
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace Microsoft.Agents.AI.Workflows.Observability;
|
||||
internal static class ActivityNames
|
||||
{
|
||||
public const string WorkflowBuild = "workflow.build";
|
||||
public const string WorkflowRun = "workflow_invoke";
|
||||
public const string WorkflowSession = "workflow.session";
|
||||
public const string WorkflowInvoke = "workflow_invoke";
|
||||
public const string MessageSend = "message.send";
|
||||
public const string ExecutorProcess = "executor.process";
|
||||
public const string EdgeGroupProcess = "edge_group.process";
|
||||
|
||||
@@ -8,6 +8,9 @@ internal static class EventNames
|
||||
public const string BuildValidationCompleted = "build.validation_completed";
|
||||
public const string BuildCompleted = "build.completed";
|
||||
public const string BuildError = "build.error";
|
||||
public const string SessionStarted = "session.started";
|
||||
public const string SessionCompleted = "session.completed";
|
||||
public const string SessionError = "session.error";
|
||||
public const string WorkflowStarted = "workflow.started";
|
||||
public const string WorkflowCompleted = "workflow.completed";
|
||||
public const string WorkflowError = "workflow.error";
|
||||
|
||||
@@ -11,6 +11,7 @@ internal static class Tags
|
||||
public const string BuildErrorMessage = "build.error.message";
|
||||
public const string BuildErrorType = "build.error.type";
|
||||
public const string ErrorType = "error.type";
|
||||
public const string ErrorMessage = "error.message";
|
||||
public const string SessionId = "session.id";
|
||||
public const string ExecutorId = "executor.id";
|
||||
public const string ExecutorType = "executor.type";
|
||||
|
||||
@@ -88,7 +88,25 @@ internal sealed class WorkflowTelemetryContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow run activity if enabled.
|
||||
/// Starts a workflow session activity if enabled. This is the outer/parent span
|
||||
/// that represents the entire lifetime of a workflow execution (from start
|
||||
/// until stop, cancellation, or error) within the current trace.
|
||||
/// Individual run stages are typically nested within it.
|
||||
/// </summary>
|
||||
/// <returns>An activity if workflow run telemetry is enabled, otherwise null.</returns>
|
||||
public Activity? StartWorkflowSessionActivity()
|
||||
{
|
||||
if (!this.IsEnabled || this.Options.DisableWorkflowRun)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.ActivitySource.StartActivity(ActivityNames.WorkflowSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow run activity if enabled. This represents a single
|
||||
/// input-to-halt cycle within a workflow session.
|
||||
/// </summary>
|
||||
/// <returns>An activity if workflow run telemetry is enabled, otherwise null.</returns>
|
||||
public Activity? StartWorkflowRunActivity()
|
||||
@@ -98,7 +116,7 @@ internal sealed class WorkflowTelemetryContext
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.ActivitySource.StartActivity(ActivityNames.WorkflowRun);
|
||||
return this.ActivitySource.StartActivity(ActivityNames.WorkflowInvoke);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -172,7 +172,7 @@ public sealed class AIAgentBuilder
|
||||
/// context enrichment, not just agents that natively support <see cref="AIContextProvider"/> instances.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public AIAgentBuilder Use(MessageAIContextProvider[] providers)
|
||||
public AIAgentBuilder UseAIContextProviders(params MessageAIContextProvider[] providers)
|
||||
{
|
||||
return this.Use((innerAgent, _) => new MessageAIContextProviderAgent(innerAgent, providers));
|
||||
}
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating chat client that enriches input messages, tools, and instructions by invoking a pipeline of
|
||||
/// <see cref="AIContextProvider"/> instances before delegating to the inner chat client, and notifies those
|
||||
/// providers after the inner client completes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This chat client must be used within the context of a running <see cref="AIAgent"/>. It retrieves the current
|
||||
/// agent and session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
|
||||
/// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> or
|
||||
/// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> method is called.
|
||||
/// An <see cref="InvalidOperationException"/> is thrown if no run context is available.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AIContextProviderChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly IReadOnlyList<AIContextProvider> _providers;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AIContextProviderChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
|
||||
/// <param name="providers">The AI context providers to invoke before and after the inner chat client.</param>
|
||||
public AIContextProviderChatClient(IChatClient innerClient, IReadOnlyList<AIContextProvider> providers)
|
||||
: base(innerClient)
|
||||
{
|
||||
Throw.IfNull(providers);
|
||||
|
||||
if (providers.Count == 0)
|
||||
{
|
||||
Throw.ArgumentException(nameof(providers), "At least one AIContextProvider must be provided.");
|
||||
}
|
||||
|
||||
this._providers = providers;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var runContext = GetRequiredRunContext();
|
||||
var (enrichedMessages, enrichedOptions) = await this.InvokeProvidersAsync(runContext, messages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatResponse response;
|
||||
try
|
||||
{
|
||||
response = await base.GetResponseAsync(enrichedMessages, enrichedOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
await this.NotifyProvidersOfSuccessAsync(runContext, enrichedMessages, response.Messages, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var runContext = GetRequiredRunContext();
|
||||
var (enrichedMessages, enrichedOptions) = await this.InvokeProvidersAsync(runContext, messages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
List<ChatResponseUpdate> responseUpdates = [];
|
||||
|
||||
IAsyncEnumerator<ChatResponseUpdate> enumerator;
|
||||
try
|
||||
{
|
||||
enumerator = base.GetStreamingResponseAsync(enrichedMessages, enrichedOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
bool hasUpdates;
|
||||
try
|
||||
{
|
||||
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
while (hasUpdates)
|
||||
{
|
||||
var update = enumerator.Current;
|
||||
responseUpdates.Add(update);
|
||||
yield return update;
|
||||
|
||||
try
|
||||
{
|
||||
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
var chatResponse = responseUpdates.ToChatResponse();
|
||||
await this.NotifyProvidersOfSuccessAsync(runContext, enrichedMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current <see cref="AgentRunContext"/>, throwing if not available.
|
||||
/// </summary>
|
||||
private static AgentRunContext GetRequiredRunContext()
|
||||
{
|
||||
return AIAgent.CurrentRunContext
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(AIContextProviderChatClient)} can only be used within the context of a running AIAgent. " +
|
||||
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes each provider's <see cref="AIContextProvider.InvokingAsync"/> in sequence,
|
||||
/// accumulating context (messages, tools, instructions) from each.
|
||||
/// </summary>
|
||||
private async Task<(IEnumerable<ChatMessage> Messages, ChatOptions? Options)> InvokeProvidersAsync(
|
||||
AgentRunContext runContext,
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var aiContext = new AIContext
|
||||
{
|
||||
Instructions = options?.Instructions,
|
||||
Messages = messages,
|
||||
Tools = options?.Tools
|
||||
};
|
||||
|
||||
foreach (var provider in this._providers)
|
||||
{
|
||||
var invokingContext = new AIContextProvider.InvokingContext(runContext.Agent, runContext.Session, aiContext);
|
||||
aiContext = await provider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Materialize the accumulated context back into messages and options.
|
||||
var enrichedMessages = aiContext.Messages ?? [];
|
||||
|
||||
var tools = aiContext.Tools as IList<AITool> ?? aiContext.Tools?.ToList();
|
||||
if (options?.Tools is { Count: > 0 } || tools is { Count: > 0 })
|
||||
{
|
||||
options ??= new();
|
||||
options.Tools = tools;
|
||||
}
|
||||
|
||||
if (options?.Instructions is not null || aiContext.Instructions is not null)
|
||||
{
|
||||
options ??= new();
|
||||
options.Instructions = aiContext.Instructions;
|
||||
}
|
||||
|
||||
return (enrichedMessages, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies each provider of a successful invocation.
|
||||
/// </summary>
|
||||
private async Task NotifyProvidersOfSuccessAsync(
|
||||
AgentRunContext runContext,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
IEnumerable<ChatMessage> responseMessages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var invokedContext = new AIContextProvider.InvokedContext(runContext.Agent, runContext.Session, requestMessages, responseMessages);
|
||||
|
||||
foreach (var provider in this._providers)
|
||||
{
|
||||
await provider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies each provider of a failed invocation.
|
||||
/// </summary>
|
||||
private async Task NotifyProvidersOfFailureAsync(
|
||||
AgentRunContext runContext,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var invokedContext = new AIContextProvider.InvokedContext(runContext.Agent, runContext.Session, requestMessages, exception);
|
||||
|
||||
foreach (var provider in this._providers)
|
||||
{
|
||||
await provider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for adding <see cref="AIContextProvider"/> support to <see cref="ChatClientBuilder"/> instances.
|
||||
/// </summary>
|
||||
public static class AIContextProviderChatClientBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds one or more <see cref="AIContextProvider"/> instances to the chat client pipeline, enabling context enrichment
|
||||
/// (messages, tools, and instructions) for any <see cref="IChatClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="ChatClientBuilder"/> to which the providers will be added.</param>
|
||||
/// <param name="providers">
|
||||
/// The <see cref="AIContextProvider"/> instances to invoke before and after each chat client call.
|
||||
/// Providers are called in sequence, with each receiving the accumulated context from the previous provider.
|
||||
/// </param>
|
||||
/// <returns>The <see cref="ChatClientBuilder"/> with the providers added, enabling method chaining.</returns>
|
||||
/// <exception cref="System.ArgumentNullException"><paramref name="builder"/> or <paramref name="providers"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="System.ArgumentException"><paramref name="providers"/> is empty.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method wraps the inner chat client with a decorator that calls each provider's
|
||||
/// <see cref="AIContextProvider.InvokingAsync"/> in sequence before the inner client is called,
|
||||
/// and calls <see cref="AIContextProvider.InvokedAsync"/> on each provider after the inner client completes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The chat client must be used within the context of a running <see cref="AIAgent"/>. The agent and session
|
||||
/// are retrieved from <see cref="AIAgent.CurrentRunContext"/>. An <see cref="System.InvalidOperationException"/>
|
||||
/// is thrown at invocation time if no run context is available.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static ChatClientBuilder UseAIContextProviders(this ChatClientBuilder builder, params AIContextProvider[] providers)
|
||||
{
|
||||
_ = Throw.IfNull(builder);
|
||||
|
||||
return builder.Use(innerClient => new AIContextProviderChatClient(innerClient, providers));
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,13 @@ internal sealed class FunctionInvocationDelegatingAgent : DelegatingAIAgent
|
||||
{
|
||||
if (options is null || options.GetType() == typeof(AgentRunOptions))
|
||||
{
|
||||
options = new ChatClientAgentRunOptions();
|
||||
options = new ChatClientAgentRunOptions()
|
||||
{
|
||||
ResponseFormat = options?.ResponseFormat,
|
||||
AllowBackgroundResponses = options?.AllowBackgroundResponses,
|
||||
ContinuationToken = options?.ContinuationToken,
|
||||
AdditionalProperties = options?.AdditionalProperties,
|
||||
};
|
||||
}
|
||||
|
||||
if (options is not ChatClientAgentRunOptions aco)
|
||||
|
||||
@@ -41,6 +41,18 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
private const string DefaultFunctionToolName = "Search";
|
||||
private const string DefaultFunctionToolDescription = "Allows searching for related previous chat history to help answer the user question.";
|
||||
|
||||
private const string KeyField = "Key";
|
||||
private const string RoleField = "Role";
|
||||
private const string MessageIdField = "MessageId";
|
||||
private const string AuthorNameField = "AuthorName";
|
||||
private const string ApplicationIdField = "ApplicationId";
|
||||
private const string AgentIdField = "AgentId";
|
||||
private const string UserIdField = "UserId";
|
||||
private const string SessionIdField = "SessionId";
|
||||
private const string ContentField = "Content";
|
||||
private const string CreatedAtField = "CreatedAt";
|
||||
private const string ContentEmbeddingField = "ContentEmbedding";
|
||||
|
||||
private readonly ProviderSessionState<State> _sessionState;
|
||||
|
||||
#pragma warning disable CA2213 // VectorStore is not owned by this class - caller is responsible for disposal
|
||||
@@ -98,17 +110,17 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
{
|
||||
Properties =
|
||||
[
|
||||
new VectorStoreKeyProperty("Key", typeof(Guid)),
|
||||
new VectorStoreDataProperty("Role", typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("MessageId", typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("AuthorName", typeof(string)),
|
||||
new VectorStoreDataProperty("ApplicationId", typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("AgentId", typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("UserId", typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("SessionId", typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("Content", typeof(string)) { IsFullTextIndexed = true },
|
||||
new VectorStoreDataProperty("CreatedAt", typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreVectorProperty("ContentEmbedding", typeof(string), Throw.IfLessThan(vectorDimensions, 1))
|
||||
new VectorStoreKeyProperty(KeyField, typeof(Guid)),
|
||||
new VectorStoreDataProperty(RoleField, typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty(MessageIdField, typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty(AuthorNameField, typeof(string)),
|
||||
new VectorStoreDataProperty(ApplicationIdField, typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty(AgentIdField, typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty(UserIdField, typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty(SessionIdField, typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty(ContentField, typeof(string)) { IsFullTextIndexed = true },
|
||||
new VectorStoreDataProperty(CreatedAtField, typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreVectorProperty(ContentEmbeddingField, typeof(string), Throw.IfLessThan(vectorDimensions, 1))
|
||||
]
|
||||
};
|
||||
|
||||
@@ -233,17 +245,17 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
.Concat(context.ResponseMessages ?? [])
|
||||
.Select(message => new Dictionary<string, object?>
|
||||
{
|
||||
["Key"] = Guid.NewGuid(),
|
||||
["Role"] = message.Role.ToString(),
|
||||
["MessageId"] = message.MessageId,
|
||||
["AuthorName"] = message.AuthorName,
|
||||
["ApplicationId"] = storageScope.ApplicationId,
|
||||
["AgentId"] = storageScope.AgentId,
|
||||
["UserId"] = storageScope.UserId,
|
||||
["SessionId"] = storageScope.SessionId,
|
||||
["Content"] = message.Text,
|
||||
["CreatedAt"] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"),
|
||||
["ContentEmbedding"] = message.Text,
|
||||
[KeyField] = Guid.NewGuid(),
|
||||
[RoleField] = message.Role.ToString(),
|
||||
[MessageIdField] = message.MessageId,
|
||||
[AuthorNameField] = message.AuthorName,
|
||||
[ApplicationIdField] = storageScope.ApplicationId,
|
||||
[AgentIdField] = storageScope.AgentId,
|
||||
[UserIdField] = storageScope.UserId,
|
||||
[SessionIdField] = storageScope.SessionId,
|
||||
[ContentField] = message.Text,
|
||||
[CreatedAtField] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"),
|
||||
[ContentEmbeddingField] = message.Text,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -288,7 +300,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
}
|
||||
|
||||
// Format the results as a single context message
|
||||
var outputResultsText = string.Join("\n", results.Select(x => (string?)x["Content"]).Where(c => !string.IsNullOrWhiteSpace(c)));
|
||||
var outputResultsText = string.Join("\n", results.Select(x => (string?)x[ContentField]).Where(c => !string.IsNullOrWhiteSpace(c)));
|
||||
if (string.IsNullOrWhiteSpace(outputResultsText))
|
||||
{
|
||||
return string.Empty;
|
||||
@@ -340,12 +352,12 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
Expression<Func<Dictionary<string, object?>, bool>>? filter = null;
|
||||
if (applicationId != null)
|
||||
{
|
||||
filter = x => (string?)x["ApplicationId"] == applicationId;
|
||||
filter = x => (string?)x[ApplicationIdField] == applicationId;
|
||||
}
|
||||
|
||||
if (agentId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x["AgentId"] == agentId;
|
||||
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId;
|
||||
filter = filter == null ? agentIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, agentIdFilter.Body),
|
||||
filter.Parameters);
|
||||
@@ -353,7 +365,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
|
||||
if (userId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x["UserId"] == userId;
|
||||
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x[UserIdField] == userId;
|
||||
filter = filter == null ? userIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, userIdFilter.Body),
|
||||
filter.Parameters);
|
||||
@@ -361,7 +373,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
|
||||
if (sessionId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x["SessionId"] == sessionId;
|
||||
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId;
|
||||
filter = filter == null ? sessionIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, sessionIdFilter.Body),
|
||||
filter.Parameters);
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a loaded Agent Skill discovered from a filesystem directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each skill is backed by a <c>SKILL.md</c> file containing YAML frontmatter (name and description)
|
||||
/// and a markdown body with instructions. Resource files referenced in the body are validated at
|
||||
/// discovery time and read from disk on demand.
|
||||
/// </remarks>
|
||||
internal sealed class FileAgentSkill
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkill"/> class.
|
||||
/// </summary>
|
||||
/// <param name="frontmatter">Parsed YAML frontmatter (name and description).</param>
|
||||
/// <param name="body">The SKILL.md content after the closing <c>---</c> delimiter.</param>
|
||||
/// <param name="sourcePath">Absolute path to the directory containing this skill.</param>
|
||||
/// <param name="resourceNames">Relative paths of resource files referenced in the skill body.</param>
|
||||
public FileAgentSkill(
|
||||
SkillFrontmatter frontmatter,
|
||||
string body,
|
||||
string sourcePath,
|
||||
IReadOnlyList<string>? resourceNames = null)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this.Body = Throw.IfNull(body);
|
||||
this.SourcePath = Throw.IfNullOrWhitespace(sourcePath);
|
||||
this.ResourceNames = resourceNames ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parsed YAML frontmatter (name and description).
|
||||
/// </summary>
|
||||
public SkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SKILL.md body content (without the YAML frontmatter).
|
||||
/// </summary>
|
||||
public string Body { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where the skill was discovered.
|
||||
/// </summary>
|
||||
public string SourcePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md").
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ResourceNames { get; }
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers, parses, and validates SKILL.md files from filesystem directories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Searches directories recursively (up to <see cref="MaxSearchDepth"/> levels) for SKILL.md files.
|
||||
/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded
|
||||
/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks.
|
||||
/// </remarks>
|
||||
internal sealed partial class FileAgentSkillLoader
|
||||
{
|
||||
private const string SkillFileName = "SKILL.md";
|
||||
private const int MaxSearchDepth = 2;
|
||||
private const int MaxNameLength = 64;
|
||||
private const int MaxDescriptionLength = 1024;
|
||||
|
||||
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
|
||||
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
|
||||
// The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend.
|
||||
// Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
|
||||
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Matches markdown links to local resource files. Group 1 = relative file path.
|
||||
// Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
|
||||
// Intentionally conservative: only matches paths with word characters, hyphens, dots,
|
||||
// and forward slashes. Paths with spaces or special characters are not supported.
|
||||
// Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json",
|
||||
// [p](../shared/doc.txt) → "../shared/doc.txt"
|
||||
private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
|
||||
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
|
||||
// Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _),
|
||||
// "description: \"A skill\"" → (description, A skill, _)
|
||||
private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Validates skill names: lowercase letters, numbers, and hyphens only; must not start or end with a hyphen.
|
||||
// Examples: "my-skill" âś“, "skill123" âś“, "-bad" âś—, "bad-" âś—, "Bad" âś—
|
||||
private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled);
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillLoader"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
internal FileAgentSkillLoader(ILogger logger)
|
||||
{
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers skill directories and loads valid skills from them.
|
||||
/// </summary>
|
||||
/// <param name="skillPaths">Paths to search for skills. Each path can point to an individual skill folder or a parent folder.</param>
|
||||
/// <returns>A dictionary of loaded skills keyed by skill name.</returns>
|
||||
internal Dictionary<string, FileAgentSkill> DiscoverAndLoadSkills(IEnumerable<string> skillPaths)
|
||||
{
|
||||
var skills = new Dictionary<string, FileAgentSkill>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var discoveredPaths = DiscoverSkillDirectories(skillPaths);
|
||||
|
||||
LogSkillsDiscovered(this._logger, discoveredPaths.Count);
|
||||
|
||||
foreach (string skillPath in discoveredPaths)
|
||||
{
|
||||
FileAgentSkill? skill = this.ParseSkillFile(skillPath);
|
||||
if (skill is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing))
|
||||
{
|
||||
LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath);
|
||||
|
||||
// Skip duplicate skill names, keeping the first one found.
|
||||
continue;
|
||||
}
|
||||
|
||||
skills[skill.Frontmatter.Name] = skill;
|
||||
|
||||
LogSkillLoaded(this._logger, skill.Frontmatter.Name);
|
||||
}
|
||||
|
||||
LogSkillsLoadedTotal(this._logger, skills.Count);
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a resource file from disk with path traversal and symlink guards.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill that owns the resource.</param>
|
||||
/// <param name="resourceName">Relative path of the resource within the skill directory.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The UTF-8 text content of the resource file.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The resource is not registered, resolves outside the skill directory, or does not exist.
|
||||
/// </exception>
|
||||
internal async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
resourceName = NormalizeResourcePath(resourceName);
|
||||
|
||||
if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
|
||||
}
|
||||
|
||||
string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName));
|
||||
string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!IsPathWithinDirectory(fullPath, normalizedSourcePath))
|
||||
{
|
||||
throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory.");
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
|
||||
}
|
||||
|
||||
if (HasSymlinkInPath(fullPath, normalizedSourcePath))
|
||||
{
|
||||
throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory.");
|
||||
}
|
||||
|
||||
LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name);
|
||||
|
||||
#if NET
|
||||
return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static List<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
|
||||
{
|
||||
var discoveredPaths = new List<string>();
|
||||
|
||||
foreach (string rootDirectory in skillPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0);
|
||||
}
|
||||
|
||||
return discoveredPaths;
|
||||
}
|
||||
|
||||
private static void SearchDirectoriesForSkills(string directory, List<string> results, int currentDepth)
|
||||
{
|
||||
string skillFilePath = Path.Combine(directory, SkillFileName);
|
||||
if (File.Exists(skillFilePath))
|
||||
{
|
||||
results.Add(Path.GetFullPath(directory));
|
||||
}
|
||||
|
||||
if (currentDepth >= MaxSearchDepth)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string subdirectory in Directory.EnumerateDirectories(directory))
|
||||
{
|
||||
SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private FileAgentSkill? ParseSkillFile(string skillDirectoryPath)
|
||||
{
|
||||
string skillFilePath = Path.Combine(skillDirectoryPath, SkillFileName);
|
||||
|
||||
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
|
||||
|
||||
if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
List<string> resourceNames = ExtractResourcePaths(body);
|
||||
|
||||
if (!this.ValidateResources(skillDirectoryPath, resourceNames, frontmatter.Name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FileAgentSkill(
|
||||
frontmatter: frontmatter,
|
||||
body: body,
|
||||
sourcePath: skillDirectoryPath,
|
||||
resourceNames: resourceNames);
|
||||
}
|
||||
|
||||
private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body)
|
||||
{
|
||||
frontmatter = null!;
|
||||
body = null!;
|
||||
|
||||
Match match = s_frontmatterRegex.Match(content);
|
||||
if (!match.Success)
|
||||
{
|
||||
LogInvalidFrontmatter(this._logger, skillFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
string? name = null;
|
||||
string? description = null;
|
||||
|
||||
string yamlContent = match.Groups[1].Value.Trim();
|
||||
|
||||
foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent))
|
||||
{
|
||||
string key = kvMatch.Groups[1].Value;
|
||||
string value = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value;
|
||||
|
||||
if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = value;
|
||||
}
|
||||
else if (string.Equals(key, "description", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
description = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
LogMissingFrontmatterField(this._logger, skillFilePath, "name");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name))
|
||||
{
|
||||
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
LogMissingFrontmatterField(this._logger, skillFilePath, "description");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (description.Length > MaxDescriptionLength)
|
||||
{
|
||||
LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer.");
|
||||
return false;
|
||||
}
|
||||
|
||||
frontmatter = new SkillFrontmatter(name, description);
|
||||
body = content.Substring(match.Index + match.Length).TrimStart();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ValidateResources(string skillDirectoryPath, List<string> resourceNames, string skillName)
|
||||
{
|
||||
string normalizedSkillPath = Path.GetFullPath(skillDirectoryPath) + Path.DirectorySeparatorChar;
|
||||
|
||||
foreach (string resourceName in resourceNames)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(Path.Combine(skillDirectoryPath, resourceName));
|
||||
|
||||
if (!IsPathWithinDirectory(fullPath, normalizedSkillPath))
|
||||
{
|
||||
LogResourcePathTraversal(this._logger, skillName, resourceName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
LogMissingResource(this._logger, skillName, resourceName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasSymlinkInPath(fullPath, normalizedSkillPath))
|
||||
{
|
||||
LogResourceSymlinkEscape(this._logger, skillName, resourceName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that <paramref name="fullPath"/> is under <paramref name="normalizedDirectoryPath"/>,
|
||||
/// guarding against path traversal attacks.
|
||||
/// </summary>
|
||||
private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath)
|
||||
{
|
||||
return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any segment in <paramref name="fullPath"/> (relative to
|
||||
/// <paramref name="normalizedDirectoryPath"/>) is a symlink (reparse point).
|
||||
/// Uses <see cref="FileAttributes.ReparsePoint"/> which is available on all target frameworks.
|
||||
/// </summary>
|
||||
private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
|
||||
{
|
||||
string relativePath = fullPath.Substring(normalizedDirectoryPath.Length);
|
||||
string[] segments = relativePath.Split(
|
||||
new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
string currentPath = normalizedDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
foreach (string segment in segments)
|
||||
{
|
||||
currentPath = Path.Combine(currentPath, segment);
|
||||
|
||||
if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<string> ExtractResourcePaths(string content)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var paths = new List<string>();
|
||||
foreach (Match m in s_resourceLinkRegex.Matches(content))
|
||||
{
|
||||
string path = NormalizeResourcePath(m.Groups[1].Value);
|
||||
if (seen.Add(path))
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a relative resource path by trimming a leading <c>./</c> prefix and replacing
|
||||
/// backslashes with forward slashes so that <c>./refs/doc.md</c> and <c>refs/doc.md</c> are
|
||||
/// treated as the same resource.
|
||||
/// </summary>
|
||||
private static string NormalizeResourcePath(string path)
|
||||
{
|
||||
if (path.IndexOf('\\') >= 0)
|
||||
{
|
||||
path = path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
if (path.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
path = path.Substring(2);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
|
||||
private static partial void LogSkillsDiscovered(ILogger logger, int count);
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Loaded skill: {SkillName}")]
|
||||
private static partial void LogSkillLoaded(ILogger logger, string skillName);
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills")]
|
||||
private static partial void LogSkillsLoadedTotal(ILogger logger, int count);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")]
|
||||
private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")]
|
||||
private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
|
||||
private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': referenced resource '{ResourceName}' does not exist")]
|
||||
private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' references a path outside the skill directory")]
|
||||
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourceName);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")]
|
||||
private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' is a symlink that resolves outside the skill directory")]
|
||||
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourceName);
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
|
||||
private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> that discovers and exposes Agent Skills from filesystem directories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider implements the progressive disclosure pattern from the
|
||||
/// <see href="https://agentskills.io/">Agent Skills specification</see>:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description><strong>Advertise</strong> — skill names and descriptions are injected into the system prompt (~100 tokens per skill).</description></item>
|
||||
/// <item><description><strong>Load</strong> — the full SKILL.md body is returned via the <c>load_skill</c> tool.</description></item>
|
||||
/// <item><description><strong>Read resources</strong> — supplementary files are read from disk on demand via the <c>read_skill_resource</c> tool.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Skills are discovered by searching the configured directories for <c>SKILL.md</c> files.
|
||||
/// Referenced resources are validated at initialization; invalid skills are excluded and logged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security:</strong> this provider only reads static content. Skill metadata is XML-escaped
|
||||
/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape.
|
||||
/// Only use skills from trusted sources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
{
|
||||
private const string DefaultSkillsInstructionPrompt =
|
||||
"""
|
||||
You have access to skills containing domain-specific knowledge and capabilities.
|
||||
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
|
||||
|
||||
<available_skills>
|
||||
{0}
|
||||
</available_skills>
|
||||
|
||||
When a task aligns with a skill's domain:
|
||||
1. Use `load_skill` to retrieve the skill's instructions
|
||||
2. Follow the provided guidance
|
||||
3. Use `read_skill_resource` to read any references or other files mentioned by the skill
|
||||
|
||||
Only load what is needed, when it is needed.
|
||||
""";
|
||||
|
||||
private readonly Dictionary<string, FileAgentSkill> _skills;
|
||||
private readonly ILogger<FileAgentSkillsProvider> _logger;
|
||||
private readonly FileAgentSkillLoader _loader;
|
||||
private readonly AITool[] _tools;
|
||||
private readonly string? _skillsInstructionPrompt;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches a single directory for skills.
|
||||
/// </summary>
|
||||
/// <param name="skillPath">Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories.</param>
|
||||
/// <param name="options">Optional configuration for prompt customization.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
: this([skillPath], options, loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches multiple directories for skills.
|
||||
/// </summary>
|
||||
/// <param name="skillPaths">Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories.</param>
|
||||
/// <param name="options">Optional configuration for prompt customization.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public FileAgentSkillsProvider(IEnumerable<string> skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(skillPaths);
|
||||
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FileAgentSkillsProvider>();
|
||||
|
||||
this._loader = new FileAgentSkillLoader(this._logger);
|
||||
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
|
||||
|
||||
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
|
||||
|
||||
this._tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
this.LoadSkill,
|
||||
name: "load_skill",
|
||||
description: "Loads the full instructions for a specific skill."),
|
||||
AIFunctionFactory.Create(
|
||||
this.ReadSkillResourceAsync,
|
||||
name: "read_skill_resource",
|
||||
description: "Reads a file associated with a skill, such as references or assets."),
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._skills.Count == 0)
|
||||
{
|
||||
return base.ProvideAIContextAsync(context, cancellationToken);
|
||||
}
|
||||
|
||||
return new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Instructions = this._skillsInstructionPrompt,
|
||||
Tools = this._tools
|
||||
});
|
||||
}
|
||||
|
||||
private string LoadSkill(string skillName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
return "Error: Skill name cannot be empty.";
|
||||
}
|
||||
|
||||
if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
LogSkillLoading(this._logger, skillName);
|
||||
|
||||
return skill.Body;
|
||||
}
|
||||
|
||||
private async Task<string> ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
return "Error: Skill name cannot be empty.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resourceName))
|
||||
{
|
||||
return "Error: Resource name cannot be empty.";
|
||||
}
|
||||
|
||||
if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogResourceReadError(this._logger, skillName, resourceName, ex);
|
||||
return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills)
|
||||
{
|
||||
string promptTemplate = DefaultSkillsInstructionPrompt;
|
||||
|
||||
if (options?.SkillsInstructionPrompt is { } optionsInstructions)
|
||||
{
|
||||
try
|
||||
{
|
||||
promptTemplate = string.Format(optionsInstructions, string.Empty);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').",
|
||||
nameof(options),
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (skills.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Order by name for deterministic prompt output across process restarts
|
||||
// (Dictionary enumeration order is not guaranteed and varies with hash randomization).
|
||||
foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine(" <skill>");
|
||||
sb.AppendLine($" <name>{SecurityElement.Escape(skill.Frontmatter.Name)}</name>");
|
||||
sb.AppendLine($" <description>{SecurityElement.Escape(skill.Frontmatter.Description)}</description>");
|
||||
sb.AppendLine(" </skill>");
|
||||
}
|
||||
|
||||
return string.Format(promptTemplate, sb.ToString().TrimEnd());
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
|
||||
private static partial void LogSkillLoading(ILogger logger, string skillName);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")]
|
||||
private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="FileAgentSkillsProvider"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkillsProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a custom system prompt template for advertising skills.
|
||||
/// Use <c>{0}</c> as the placeholder for the generated skills list.
|
||||
/// When <see langword="null"/>, a default template is used.
|
||||
/// </summary>
|
||||
public string? SkillsInstructionPrompt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description.
|
||||
/// </summary>
|
||||
internal sealed class SkillFrontmatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkillFrontmatter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">Skill name.</param>
|
||||
/// <param name="description">Skill description.</param>
|
||||
public SkillFrontmatter(string name, string description)
|
||||
{
|
||||
this.Name = Throw.IfNullOrWhitespace(name);
|
||||
this.Description = Throw.IfNullOrWhitespace(description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill name. Lowercase letters, numbers, and hyphens only.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill description. Used for discovery in the system prompt.
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Shared.IntegrationTests;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
|
||||
|
||||
internal sealed class FoundryMemoryConfiguration
|
||||
{
|
||||
public string Endpoint { get; set; }
|
||||
public string MemoryStoreName { get; set; }
|
||||
public string? DeploymentName { get; set; }
|
||||
}
|
||||
@@ -22,6 +22,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
|
||||
// Assign to enable logging
|
||||
public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance;
|
||||
|
||||
// Assign to provide MCP tool capabilities
|
||||
public IMcpToolHandler? McpToolHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Create the workflow from the declarative YAML. Includes definition of the
|
||||
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
|
||||
@@ -42,6 +45,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
|
||||
Configuration = this.Configuration,
|
||||
ConversationId = this.ConversationId,
|
||||
LoggerFactory = this.LoggerFactory,
|
||||
McpToolHandler = this.McpToolHandler,
|
||||
};
|
||||
|
||||
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<IsAotCompatible>false</IsAotCompatible>
|
||||
<TargetFrameworks>net10.0;net472</TargetFrameworks>
|
||||
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
|
||||
<NoWarn>$(NoWarn);Moq1410;xUnit2023</NoWarn>
|
||||
<NoWarn>$(NoWarn);Moq1410;xUnit2023;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+125
-7
@@ -43,9 +43,9 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
private static AgentSession CreateMockSession() => new Moq.Mock<AgentSession>().Object;
|
||||
|
||||
// Cosmos DB Emulator connection settings
|
||||
private const string EmulatorEndpoint = "https://localhost:8081";
|
||||
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
|
||||
// Cosmos DB Emulator connection settings (can be overridden via COSMOSDB_ENDPOINT and COSMOSDB_KEY environment variables)
|
||||
private static readonly string s_emulatorEndpoint = Environment.GetEnvironmentVariable("COSMOSDB_ENDPOINT") ?? "https://localhost:8081";
|
||||
private static readonly string s_emulatorKey = Environment.GetEnvironmentVariable("COSMOSDB_KEY") ?? "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
|
||||
private const string TestContainerId = "ChatMessages";
|
||||
private const string HierarchicalTestContainerId = "HierarchicalChatMessages";
|
||||
// Use unique database ID per test class instance to avoid conflicts
|
||||
@@ -67,12 +67,12 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
// Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection
|
||||
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
this._connectionString = $"AccountEndpoint={EmulatorEndpoint};AccountKey={EmulatorKey}";
|
||||
this._connectionString = $"AccountEndpoint={s_emulatorEndpoint};AccountKey={s_emulatorKey}";
|
||||
|
||||
try
|
||||
{
|
||||
// Only create CosmosClient for test setup - the actual tests will use connection string constructors
|
||||
this._setupClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
this._setupClient = new CosmosClient(s_emulatorEndpoint, s_emulatorKey);
|
||||
|
||||
// Test connection by attempting to create database
|
||||
var databaseResponse = await this._setupClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId);
|
||||
@@ -497,7 +497,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
// Act
|
||||
TokenCredential credential = new DefaultAzureCredential();
|
||||
using var provider = new CosmosChatHistoryProvider(EmulatorEndpoint, credential, s_testDatabaseId, HierarchicalTestContainerId,
|
||||
using var provider = new CosmosChatHistoryProvider(s_emulatorEndpoint, credential, s_testDatabaseId, HierarchicalTestContainerId,
|
||||
_ => new CosmosChatHistoryProvider.State("session-789", "tenant-123", "user-456"));
|
||||
|
||||
// Assert
|
||||
@@ -513,7 +513,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
// Arrange & Act
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
using var cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
using var cosmosClient = new CosmosClient(s_emulatorEndpoint, s_emulatorKey);
|
||||
using var provider = new CosmosChatHistoryProvider(cosmosClient, s_testDatabaseId, HierarchicalTestContainerId,
|
||||
_ => new CosmosChatHistoryProvider.State("session-789", "tenant-123", "user-456"));
|
||||
|
||||
@@ -834,6 +834,124 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Message 10", messageList[9].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task GetMessageCountAsync_WithMessages_ShouldReturnCorrectCountAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var session = CreateMockSession();
|
||||
const string ConversationId = "count-test-conversation";
|
||||
|
||||
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId,
|
||||
_ => new CosmosChatHistoryProvider.State(ConversationId));
|
||||
|
||||
// Add 5 messages
|
||||
var messages = new List<ChatMessage>();
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
messages.Add(new ChatMessage(ChatRole.User, $"Message {i}"));
|
||||
}
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []);
|
||||
await provider.InvokedAsync(context);
|
||||
|
||||
// Wait for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act
|
||||
var count = await provider.GetMessageCountAsync(session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task GetMessageCountAsync_WithNoMessages_ShouldReturnZeroAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var session = CreateMockSession();
|
||||
const string ConversationId = "empty-count-test-conversation";
|
||||
|
||||
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId,
|
||||
_ => new CosmosChatHistoryProvider.State(ConversationId));
|
||||
|
||||
// Act
|
||||
var count = await provider.GetMessageCountAsync(session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task ClearMessagesAsync_WithMessages_ShouldDeleteAndReturnCountAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var session = CreateMockSession();
|
||||
const string ConversationId = "clear-test-conversation";
|
||||
|
||||
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId,
|
||||
_ => new CosmosChatHistoryProvider.State(ConversationId));
|
||||
|
||||
// Add 3 messages
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Message 1"),
|
||||
new(ChatRole.Assistant, "Message 2"),
|
||||
new(ChatRole.User, "Message 3")
|
||||
};
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []);
|
||||
await provider.InvokedAsync(context);
|
||||
|
||||
// Wait for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Verify messages exist
|
||||
var countBefore = await provider.GetMessageCountAsync(session);
|
||||
Assert.Equal(3, countBefore);
|
||||
|
||||
// Act
|
||||
var deletedCount = await provider.ClearMessagesAsync(session);
|
||||
|
||||
// Wait for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, deletedCount);
|
||||
|
||||
// Verify messages are deleted
|
||||
var countAfter = await provider.GetMessageCountAsync(session);
|
||||
Assert.Equal(0, countAfter);
|
||||
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []);
|
||||
var retrievedMessages = await provider.InvokingAsync(invokingContext);
|
||||
Assert.Empty(retrievedMessages);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task ClearMessagesAsync_WithNoMessages_ShouldReturnZeroAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var session = CreateMockSession();
|
||||
const string ConversationId = "empty-clear-test-conversation";
|
||||
|
||||
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId,
|
||||
_ => new CosmosChatHistoryProvider.State(ConversationId));
|
||||
|
||||
// Act
|
||||
var deletedCount = await provider.ClearMessagesAsync(session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, deletedCount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Message Filter Tests
|
||||
|
||||
+5
-5
@@ -28,9 +28,9 @@ namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
|
||||
[Collection("CosmosDB")]
|
||||
public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
{
|
||||
// Cosmos DB Emulator connection settings
|
||||
private const string EmulatorEndpoint = "https://localhost:8081";
|
||||
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
|
||||
// Cosmos DB Emulator connection settings (can be overridden via COSMOSDB_ENDPOINT and COSMOSDB_KEY environment variables)
|
||||
private static readonly string s_emulatorEndpoint = Environment.GetEnvironmentVariable("COSMOSDB_ENDPOINT") ?? "https://localhost:8081";
|
||||
private static readonly string s_emulatorKey = Environment.GetEnvironmentVariable("COSMOSDB_KEY") ?? "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
|
||||
private const string TestContainerId = "Checkpoints";
|
||||
// Use unique database ID per test class instance to avoid conflicts
|
||||
#pragma warning disable CA1802 // Use literals where appropriate
|
||||
@@ -64,11 +64,11 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
// Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection
|
||||
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
this._connectionString = $"AccountEndpoint={EmulatorEndpoint};AccountKey={EmulatorKey}";
|
||||
this._connectionString = $"AccountEndpoint={s_emulatorEndpoint};AccountKey={s_emulatorKey}";
|
||||
|
||||
try
|
||||
{
|
||||
this._cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
this._cosmosClient = new CosmosClient(s_emulatorEndpoint, s_emulatorKey);
|
||||
|
||||
// Test connection by attempting to create database
|
||||
this._database = await this._cosmosClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId);
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="FoundryMemoryProvider"/> against a configured Azure AI Foundry Memory service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These integration tests are skipped by default and require a live Azure AI Foundry Memory service.
|
||||
/// The tests need to be updated to use the new AIAgent-based API pattern.
|
||||
/// Set <see cref="SkipReason"/> to null to enable them after configuring the service.
|
||||
/// </remarks>
|
||||
public sealed class FoundryMemoryProviderTests : IDisposable
|
||||
{
|
||||
private const string SkipReason = "Requires an Azure AI Foundry Memory service configured"; // Set to null to enable.
|
||||
|
||||
private readonly AIProjectClient? _client;
|
||||
private readonly string? _memoryStoreName;
|
||||
private readonly string? _deploymentName;
|
||||
private bool _disposed;
|
||||
|
||||
public FoundryMemoryProviderTests()
|
||||
{
|
||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables()
|
||||
.AddUserSecrets<FoundryMemoryProviderTests>(optional: true)
|
||||
.Build();
|
||||
|
||||
var foundrySettings = configuration.GetSection("FoundryMemory").Get<FoundryMemoryConfiguration>();
|
||||
|
||||
if (foundrySettings is not null &&
|
||||
!string.IsNullOrWhiteSpace(foundrySettings.Endpoint) &&
|
||||
!string.IsNullOrWhiteSpace(foundrySettings.MemoryStoreName))
|
||||
{
|
||||
this._client = new AIProjectClient(new Uri(foundrySettings.Endpoint), new AzureCliCredential());
|
||||
this._memoryStoreName = foundrySettings.MemoryStoreName;
|
||||
this._deploymentName = foundrySettings.DeploymentName ?? "gpt-4.1-mini";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task CanAddAndRetrieveUserMemoriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
FoundryMemoryProvider memoryProvider = new(
|
||||
this._client!,
|
||||
this._memoryStoreName!,
|
||||
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-user-1")));
|
||||
|
||||
AIAgent agent = await this._client!.CreateAIAgentAsync(this._deploymentName!,
|
||||
options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider] });
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
|
||||
|
||||
// Act
|
||||
AgentResponse resultBefore = await agent.RunAsync("What is my name?", session);
|
||||
Assert.DoesNotContain("Caoimhe", resultBefore.Text);
|
||||
|
||||
await agent.RunAsync("Hello, my name is Caoimhe.", session);
|
||||
await memoryProvider.WhenUpdatesCompletedAsync();
|
||||
await Task.Delay(2000);
|
||||
|
||||
AgentResponse resultAfter = await agent.RunAsync("What is my name?", session);
|
||||
|
||||
// Cleanup
|
||||
await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Caoimhe", resultAfter.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task DoesNotLeakMemoriesAcrossScopesAsync()
|
||||
{
|
||||
// Arrange
|
||||
FoundryMemoryProvider memoryProvider1 = new(
|
||||
this._client!,
|
||||
this._memoryStoreName!,
|
||||
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-a")));
|
||||
|
||||
FoundryMemoryProvider memoryProvider2 = new(
|
||||
this._client!,
|
||||
this._memoryStoreName!,
|
||||
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-b")));
|
||||
|
||||
AIAgent agent1 = await this._client!.CreateAIAgentAsync(this._deploymentName!,
|
||||
options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider1] });
|
||||
AIAgent agent2 = await this._client!.CreateAIAgentAsync(this._deploymentName!,
|
||||
options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider2] });
|
||||
|
||||
AgentSession session1 = await agent1.CreateSessionAsync();
|
||||
AgentSession session2 = await agent2.CreateSessionAsync();
|
||||
|
||||
await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1);
|
||||
await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2);
|
||||
|
||||
// Act - add memory only to scope A
|
||||
await agent1.RunAsync("Hello, I'm an AI tutor and my name is Caoimhe.", session1);
|
||||
await memoryProvider1.WhenUpdatesCompletedAsync();
|
||||
await Task.Delay(2000);
|
||||
|
||||
AgentResponse result1 = await agent1.RunAsync("What is your name?", session1);
|
||||
AgentResponse result2 = await agent2.RunAsync("What is your name?", session2);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Caoimhe", result1.Text);
|
||||
Assert.DoesNotContain("Caoimhe", result2.Text);
|
||||
|
||||
// Cleanup
|
||||
await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1);
|
||||
await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!this._disposed)
|
||||
{
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user