mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
42
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 |
@@ -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
|
||||
@@ -7,13 +7,6 @@ name: dotnet-build-and-test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Git ref to checkout (e.g., a commit SHA from a PR)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
pull_request:
|
||||
branches: ["main", "feature*"]
|
||||
merge_group:
|
||||
@@ -46,8 +39,6 @@ jobs:
|
||||
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -85,7 +76,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.
|
||||
|
||||
@@ -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 }}
|
||||
@@ -2,8 +2,9 @@
|
||||
# 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 reuses the existing dotnet-build-and-test and python-merge-tests workflows,
|
||||
# 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)
|
||||
@@ -37,6 +38,8 @@ jobs:
|
||||
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
|
||||
@@ -45,7 +48,6 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then
|
||||
echo "::error::Please provide either a PR number or a branch name, not both."
|
||||
@@ -63,20 +65,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state,headRepository,headRepositoryOwner)
|
||||
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state)
|
||||
PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
|
||||
HEAD_OWNER=$(echo "$PR_DATA" | jq -r '.headRepositoryOwner.login')
|
||||
|
||||
if [ "$PR_STATE" != "OPEN" ]; then
|
||||
echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$HEAD_OWNER" != "$REPO_OWNER" ]; then
|
||||
echo "::error::PR #$PR_NUMBER is from a fork ($HEAD_OWNER). Running integration tests against fork PRs is not allowed for security reasons."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT"
|
||||
echo "Running integration tests for PR #$PR_NUMBER"
|
||||
else
|
||||
@@ -89,10 +85,41 @@ jobs:
|
||||
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
|
||||
uses: ./.github/workflows/dotnet-build-and-test.yml
|
||||
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
|
||||
@@ -100,7 +127,8 @@ jobs:
|
||||
python-integration-tests:
|
||||
name: Python Integration Tests
|
||||
needs: resolve-ref
|
||||
uses: ./.github/workflows/python-merge-tests.yml
|
||||
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,14 +1,12 @@
|
||||
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:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Git ref to checkout (e.g., a commit SHA from a PR)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
merge_group:
|
||||
@@ -17,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:
|
||||
@@ -33,17 +31,42 @@ 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
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
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'
|
||||
@@ -52,50 +75,237 @@ 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
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
- name: Set up python and install the project
|
||||
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
|
||||
@@ -106,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()
|
||||
@@ -122,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 }}
|
||||
@@ -146,17 +356,12 @@ jobs:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
- name: Set up python and install the project
|
||||
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
|
||||
@@ -166,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
|
||||
@@ -190,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.
|
||||
@@ -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" />
|
||||
|
||||
@@ -181,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>
|
||||
@@ -223,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" />
|
||||
@@ -434,6 +440,7 @@
|
||||
<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" />
|
||||
@@ -479,6 +486,7 @@
|
||||
<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."));
|
||||
|
||||
@@ -8,3 +8,5 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|
||||
|[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|
|
||||
|
||||
|
||||
+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");
|
||||
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -18,10 +19,11 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
public sealed class AIAgentExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync are null.
|
||||
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have
|
||||
/// AllowBackgroundResponses enabled and no AdditionalProperties.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync()
|
||||
public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
@@ -35,7 +37,9 @@ public sealed class AIAgentExtensionsTests
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Null(capturedOptions);
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
Assert.Null(capturedOptions.AdditionalProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,11 +72,11 @@ public sealed class AIAgentExtensionsTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync is null
|
||||
/// because the ToAdditionalProperties extension method returns null for empty dictionaries.
|
||||
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have
|
||||
/// AllowBackgroundResponses enabled and no AdditionalProperties.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsync()
|
||||
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
@@ -86,7 +90,9 @@ public sealed class AIAgentExtensionsTests
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Null(capturedOptions);
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
Assert.Null(capturedOptions.AdditionalProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -171,6 +177,590 @@ public sealed class AIAgentExtensionsTests
|
||||
Assert.Null(agentMessage.Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when runMode is Message, the result is always an AgentMessage even when
|
||||
/// the agent would otherwise support background responses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
|
||||
.Object.MapA2A(runMode: AgentRunMode.DisallowBackground);
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.IsType<AgentMessage>(a2aResponse);
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
|
||||
/// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
|
||||
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.IsType<AgentMessage>(a2aResponse);
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.True(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a custom Dynamic delegate returning false produces an AgentMessage
|
||||
/// even when the agent completes immediately (no ContinuationToken).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]);
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response)
|
||||
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.IsType<AgentMessage>(a2aResponse);
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
Assert.Equal(TaskState.Working, agentTask.Status.State);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent returns a ContinuationToken, the returned task includes
|
||||
/// intermediate messages from the initial response in its status message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
Assert.NotNull(agentTask.Status.Message);
|
||||
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(agentTask.Status.Message.Parts));
|
||||
Assert.Equal("Starting work...", textPart.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent returns a ContinuationToken, the continuation token
|
||||
/// is serialized into the AgentTask.Metadata for persistence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
Assert.NotNull(agentTask.Metadata);
|
||||
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a task is created (Working or Completed), the original user message
|
||||
/// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] };
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = originalMessage
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
Assert.NotNull(agentTask.History);
|
||||
Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
|
||||
/// the returned AgentMessage preserves the original context ID.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response)
|
||||
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
|
||||
AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] };
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = originalMessage
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
|
||||
Assert.Equal("ctx-123", agentMessage.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
|
||||
/// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync()
|
||||
{
|
||||
// Arrange
|
||||
int callCount = 0;
|
||||
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
|
||||
// First call: return response with ContinuationToken (long-running)
|
||||
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
},
|
||||
// Second call (via OnTaskUpdated): return completed response
|
||||
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
|
||||
ref callCount);
|
||||
ITaskManager taskManager = agentMock.Object.MapA2A();
|
||||
|
||||
// Act — trigger OnMessageReceived to create the task
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
Assert.Equal(TaskState.Working, agentTask.Status.State);
|
||||
|
||||
// Act — invoke OnTaskUpdated to check on the background operation
|
||||
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
|
||||
|
||||
// Assert — task should now be completed
|
||||
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
|
||||
Assert.NotNull(updatedTask);
|
||||
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
|
||||
Assert.NotNull(updatedTask.Artifacts);
|
||||
Artifact artifact = Assert.Single(updatedTask.Artifacts);
|
||||
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
|
||||
Assert.Equal("Done!", textPart.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
|
||||
/// and the agent returns another ContinuationToken, the task stays in Working state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync()
|
||||
{
|
||||
// Arrange
|
||||
int callCount = 0;
|
||||
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
|
||||
// First call: return response with ContinuationToken
|
||||
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
},
|
||||
// Second call (via OnTaskUpdated): still working, return another token
|
||||
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
},
|
||||
ref callCount);
|
||||
ITaskManager taskManager = agentMock.Object.MapA2A();
|
||||
|
||||
// Act — trigger OnMessageReceived to create the task
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
|
||||
// Act — invoke OnTaskUpdated; agent still working
|
||||
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
|
||||
|
||||
// Assert — task should still be in Working state
|
||||
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
|
||||
Assert.NotNull(updatedTask);
|
||||
Assert.Equal(TaskState.Working, updatedTask.Status.State);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the full lifecycle: agent starts background work, first poll returns still working,
|
||||
/// second poll returns completed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync()
|
||||
{
|
||||
// Arrange
|
||||
int callCount = 0;
|
||||
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
|
||||
{
|
||||
return invocation switch
|
||||
{
|
||||
// First call: start background work
|
||||
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
},
|
||||
// Second call: still working
|
||||
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
},
|
||||
// Third call: done
|
||||
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")])
|
||||
};
|
||||
});
|
||||
ITaskManager taskManager = agentMock.Object.MapA2A();
|
||||
|
||||
// Act — create the task
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] }
|
||||
});
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
Assert.Equal(TaskState.Working, agentTask.Status.State);
|
||||
|
||||
// Act — first poll: still working
|
||||
AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
|
||||
Assert.NotNull(currentTask);
|
||||
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
|
||||
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
|
||||
Assert.NotNull(currentTask);
|
||||
Assert.Equal(TaskState.Working, currentTask.Status.State);
|
||||
|
||||
// Act — second poll: completed
|
||||
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
|
||||
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
|
||||
Assert.NotNull(currentTask);
|
||||
Assert.Equal(TaskState.Completed, currentTask.Status.State);
|
||||
|
||||
// Assert — final output as artifact
|
||||
Assert.NotNull(currentTask.Artifacts);
|
||||
Artifact artifact = Assert.Single(currentTask.Artifacts);
|
||||
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
|
||||
Assert.Equal("All done!", textPart.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent throws during a background operation poll,
|
||||
/// the task is updated to Failed state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync()
|
||||
{
|
||||
// Arrange
|
||||
int callCount = 0;
|
||||
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
|
||||
{
|
||||
if (invocation == 1)
|
||||
{
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Agent failed");
|
||||
});
|
||||
ITaskManager taskManager = agentMock.Object.MapA2A();
|
||||
|
||||
// Act — create the task
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
|
||||
// Act — poll the task; agent throws
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
|
||||
|
||||
// Assert — task should be Failed
|
||||
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
|
||||
Assert.NotNull(updatedTask);
|
||||
Assert.Equal(TaskState.Failed, updatedTask.Status.State);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response)
|
||||
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
Assert.Equal(TaskState.Working, agentTask.Status.State);
|
||||
Assert.NotNull(agentTask.Metadata);
|
||||
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent returns a ContinuationToken with no progress messages,
|
||||
/// the task transitions to Working state with a null status message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
Assert.Equal(TaskState.Working, agentTask.Status.State);
|
||||
Assert.Null(agentTask.Status.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message
|
||||
/// and no continuation token in metadata, the task processes history and completes with a new artifact.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync()
|
||||
{
|
||||
// Arrange
|
||||
int callCount = 0;
|
||||
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
|
||||
{
|
||||
return invocation switch
|
||||
{
|
||||
// First call: create a task with ContinuationToken
|
||||
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
},
|
||||
// Second call (via OnTaskUpdated): complete the background operation
|
||||
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
|
||||
// Third call (follow-up via OnTaskUpdated): complete follow-up
|
||||
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")])
|
||||
};
|
||||
});
|
||||
ITaskManager taskManager = agentMock.Object.MapA2A();
|
||||
|
||||
// Act — create a working task (with continuation token)
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
|
||||
// Act — first OnTaskUpdated: completes the background operation
|
||||
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
|
||||
agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!;
|
||||
Assert.Equal(TaskState.Completed, agentTask.Status.State);
|
||||
|
||||
// Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated
|
||||
agentTask.History ??= [];
|
||||
agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] });
|
||||
|
||||
// Act — invoke OnTaskUpdated without a continuation token in metadata
|
||||
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
|
||||
|
||||
// Assert
|
||||
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
|
||||
Assert.NotNull(updatedTask);
|
||||
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
|
||||
Assert.NotNull(updatedTask.Artifacts);
|
||||
Assert.Equal(2, updatedTask.Artifacts.Count);
|
||||
Artifact artifact = updatedTask.Artifacts[1];
|
||||
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
|
||||
Assert.Equal("Follow-up done!", textPart.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a task is cancelled, the continuation token is removed from metadata.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
|
||||
// Act — create a working task with a continuation token
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
Assert.NotNull(agentTask.Metadata);
|
||||
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
|
||||
|
||||
// Act — cancel the task
|
||||
await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None);
|
||||
|
||||
// Assert — continuation token should be removed from metadata
|
||||
Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent throws an OperationCanceledException during a poll,
|
||||
/// it is re-thrown without marking the task as Failed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync()
|
||||
{
|
||||
// Arrange
|
||||
int callCount = 0;
|
||||
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
|
||||
{
|
||||
if (invocation == 1)
|
||||
{
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
}
|
||||
|
||||
throw new OperationCanceledException("Cancelled");
|
||||
});
|
||||
ITaskManager taskManager = agentMock.Object.MapA2A();
|
||||
|
||||
// Act — create the task
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
|
||||
|
||||
// Act — poll the task; agent throws OperationCanceledException
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
|
||||
|
||||
// Assert — task should still be Working, not Failed
|
||||
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
|
||||
Assert.NotNull(updatedTask);
|
||||
Assert.Equal(TaskState.Working, updatedTask.Status.State);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the incoming message has a ContextId, it is used for the task
|
||||
/// rather than generating a new one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage
|
||||
{
|
||||
MessageId = "test-id",
|
||||
ContextId = "my-context-123",
|
||||
Role = MessageRole.User,
|
||||
Parts = [new TextPart { Text = "Hello" }]
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
|
||||
Assert.Equal("my-context-123", agentMessage.ContextId);
|
||||
}
|
||||
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
@@ -220,5 +810,57 @@ public sealed class AIAgentExtensionsTests
|
||||
return await handler.Invoke(messageSendParams, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask)
|
||||
{
|
||||
Func<AgentTask, CancellationToken, Task>? handler = taskManager.OnTaskUpdated;
|
||||
Assert.NotNull(handler);
|
||||
await handler.Invoke(agentTask, CancellationToken.None);
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
private static ResponseContinuationToken CreateTestContinuationToken()
|
||||
{
|
||||
return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 });
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMockWithSequentialResponses(
|
||||
AgentResponse firstResponse,
|
||||
AgentResponse secondResponse,
|
||||
ref int callCount)
|
||||
{
|
||||
return CreateAgentMockWithCallCount(ref callCount, invocation =>
|
||||
invocation == 1 ? firstResponse : secondResponse);
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMockWithCallCount(
|
||||
ref int callCount,
|
||||
Func<int, AgentResponse> responseFactory)
|
||||
{
|
||||
// Use a StrongBox to allow the lambda to capture a mutable reference
|
||||
StrongBox<int> callCountBox = new(callCount);
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
int currentCall = Interlocked.Increment(ref callCountBox.Value);
|
||||
return responseFactory(currentCall);
|
||||
});
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
}
|
||||
|
||||
+32
@@ -259,6 +259,38 @@ public sealed class OpenAIResponseClientExtensionsTests
|
||||
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_WithNullClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
((ResponsesClient)null!).AsIChatClientWithStoredOutputDisabled());
|
||||
|
||||
Assert.Equal("responseClient", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsIChatClientWithStoredOutputDisabled wraps the original ResponsesClient,
|
||||
/// which remains accessible via the service chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsIChatClientWithStoredOutputDisabled_InnerResponsesClientIsAccessible()
|
||||
{
|
||||
// Arrange
|
||||
var responseClient = new TestOpenAIResponseClient();
|
||||
|
||||
// Act
|
||||
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
|
||||
|
||||
// Assert - the inner ResponsesClient should be accessible via GetService
|
||||
var innerClient = chatClient.GetService<ResponsesClient>();
|
||||
Assert.NotNull(innerClient);
|
||||
Assert.Same(responseClient, innerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple test IServiceProvider implementation for testing.
|
||||
/// </summary>
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class PurviewWrapperTests : IDisposable
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
Activity.UploadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
@@ -88,15 +88,24 @@ public sealed class PurviewWrapperTests : IDisposable
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerResponse);
|
||||
|
||||
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
|
||||
// Prompt check uses UploadText, response check uses DownloadText
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
Activity.UploadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((false, "user-123")) // Prompt allowed
|
||||
.ReturnsAsync((true, "user-123")); // Response blocked
|
||||
.ReturnsAsync((false, "user-123")); // Prompt allowed
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
Activity.DownloadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((true, "user-123")); // Response blocked
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None);
|
||||
@@ -237,14 +246,21 @@ public sealed class PurviewWrapperTests : IDisposable
|
||||
// Act
|
||||
await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
// Assert - verify prompt uses UploadText and response uses DownloadText
|
||||
this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
"conversation-123",
|
||||
It.IsAny<Activity>(),
|
||||
Activity.UploadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
"conversation-123",
|
||||
Activity.DownloadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -264,7 +280,7 @@ public sealed class PurviewWrapperTests : IDisposable
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
Activity.UploadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
@@ -306,15 +322,24 @@ public sealed class PurviewWrapperTests : IDisposable
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(innerResponse);
|
||||
|
||||
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
|
||||
// Prompt check uses UploadText, response check uses DownloadText
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
Activity.UploadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((false, "user-123")) // Prompt allowed
|
||||
.ReturnsAsync((true, "user-123")); // Response blocked
|
||||
.ReturnsAsync((false, "user-123")); // Prompt allowed
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
Activity.DownloadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((true, "user-123")); // Response blocked
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
|
||||
@@ -472,10 +497,17 @@ public sealed class PurviewWrapperTests : IDisposable
|
||||
this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
"conversation-from-props",
|
||||
It.IsAny<Activity>(),
|
||||
Activity.UploadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
"conversation-from-props",
|
||||
Activity.DownloadText,
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIContextProviderChatClient"/> class and
|
||||
/// the <see cref="AIContextProviderChatClientBuilderExtensions.UseAIContextProviders(ChatClientBuilder, AIContextProvider[])"/> builder extension.
|
||||
/// </summary>
|
||||
public class AIContextProviderChatClientTests
|
||||
{
|
||||
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullInnerClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestAIContextProvider("key1");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProviderChatClient(null!, [provider]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullProviders_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProviderChatClient(innerClient, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EmptyProviders_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AIContextProviderChatClient(innerClient, []));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetResponseAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NoRunContext_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = new Mock<IChatClient>();
|
||||
var provider = new TestAIContextProvider("key1");
|
||||
var chatClient = new AIContextProviderChatClient(innerClient.Object, [provider]);
|
||||
|
||||
// Act & Assert — no AIAgent.CurrentRunContext is set
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => chatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_SingleProvider_EnrichesMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (messages, _, _) =>
|
||||
{
|
||||
capturedMessages = messages;
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideMessages: [new ChatMessage(ChatRole.System, "Extra context")]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
// Act — run through an agent so CurrentRunContext is set
|
||||
await RunWithAgentContextAsync(chatClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedMessages);
|
||||
var messageList = capturedMessages!.ToList();
|
||||
Assert.Equal(2, messageList.Count);
|
||||
Assert.Equal("Hello", messageList[0].Text);
|
||||
Assert.Contains("Extra context", messageList[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_MultipleProviders_CalledInSequenceAsync()
|
||||
{
|
||||
// Arrange
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (messages, _, _) =>
|
||||
{
|
||||
capturedMessages = messages;
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
|
||||
});
|
||||
|
||||
var provider1 = new TestAIContextProvider("key1", provideMessages: [new ChatMessage(ChatRole.System, "From P1")]);
|
||||
var provider2 = new TestAIContextProvider("key2", provideMessages: [new ChatMessage(ChatRole.System, "From P2")]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider1, provider2]);
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(chatClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedMessages);
|
||||
var messageList = capturedMessages!.ToList();
|
||||
Assert.Equal(3, messageList.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_Provider_EnrichesToolsAndInstructionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (_, options, _) =>
|
||||
{
|
||||
capturedOptions = options;
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideInstructions: "Extra instructions", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(chatClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Equal("Extra instructions", capturedOptions!.Instructions);
|
||||
Assert.Single(capturedOptions.Tools!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_OnSuccess_InvokedAsyncCalledAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (_, _, _) => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
|
||||
|
||||
var provider = new TestAIContextProvider("key1");
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(chatClient);
|
||||
|
||||
// Assert
|
||||
Assert.True(provider.InvokedAsyncCalled);
|
||||
Assert.Null(provider.LastInvokedContext!.InvokeException);
|
||||
Assert.NotNull(provider.LastInvokedContext.ResponseMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_OnFailure_InvokedAsyncCalledWithExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var expectedException = new InvalidOperationException("Chat failed");
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (_, _, _) => throw expectedException);
|
||||
|
||||
var provider = new TestAIContextProvider("key1");
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => RunWithAgentContextAsync(chatClient));
|
||||
|
||||
Assert.True(provider.InvokedAsyncCalled);
|
||||
Assert.Same(expectedException, provider.LastInvokedContext!.InvokeException);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStreamingResponseAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_SingleProvider_EnrichesAndStreamsAsync()
|
||||
{
|
||||
// Arrange
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
var innerClient = CreateMockStreamingChatClient(
|
||||
onGetStreamingResponse: (messages, _, _) =>
|
||||
{
|
||||
capturedMessages = messages;
|
||||
return ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Part1"),
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Part2"));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideMessages: [new ChatMessage(ChatRole.System, "Extra context")]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
// Act
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await RunStreamingWithAgentContextAsync(chatClient, updates);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, updates.Count);
|
||||
Assert.NotNull(capturedMessages);
|
||||
Assert.Equal(2, capturedMessages!.ToList().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_OnSuccess_InvokedAsyncCalledAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockStreamingChatClient(
|
||||
onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Response")));
|
||||
|
||||
var provider = new TestAIContextProvider("key1");
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
// Act
|
||||
await RunStreamingWithAgentContextAsync(chatClient, []);
|
||||
|
||||
// Assert
|
||||
Assert.True(provider.InvokedAsyncCalled);
|
||||
Assert.Null(provider.LastInvokedContext!.InvokeException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_OnFailure_InvokedAsyncCalledWithExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var expectedException = new InvalidOperationException("Stream failed");
|
||||
var innerClient = CreateMockStreamingChatClient(
|
||||
onGetStreamingResponse: (_, _, _) => throw expectedException);
|
||||
|
||||
var provider = new TestAIContextProvider("key1");
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => RunStreamingWithAgentContextAsync(chatClient, []));
|
||||
|
||||
Assert.True(provider.InvokedAsyncCalled);
|
||||
Assert.Same(expectedException, provider.LastInvokedContext!.InvokeException);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Builder Extension Tests
|
||||
|
||||
[Fact]
|
||||
public void UseExtension_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestAIContextProvider("key1");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
AIContextProviderChatClientBuilderExtensions.UseAIContextProviders(null!, provider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UseExtension_CreatesWorkingPipelineAsync()
|
||||
{
|
||||
// Arrange
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (messages, _, _) =>
|
||||
{
|
||||
capturedMessages = messages;
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideMessages: [new ChatMessage(ChatRole.System, "Pipeline context")]);
|
||||
|
||||
var pipeline = new ChatClientBuilder(innerClient)
|
||||
.UseAIContextProviders(provider)
|
||||
.Build();
|
||||
|
||||
// Act — wrap in an agent to set CurrentRunContext
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, session, options, ct) =>
|
||||
{
|
||||
var response = await pipeline.GetResponseAsync(messages, cancellationToken: ct);
|
||||
return new AgentResponse(response);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedMessages);
|
||||
var messageList = capturedMessages!.ToList();
|
||||
Assert.Equal(2, messageList.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Runs a chat client within an agent context so that AIAgent.CurrentRunContext is set.
|
||||
/// </summary>
|
||||
private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, session, options, ct) =>
|
||||
{
|
||||
var response = await chatClient.GetResponseAsync(messages, cancellationToken: ct);
|
||||
return new AgentResponse(response);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a streaming chat client within an agent context so that AIAgent.CurrentRunContext is set.
|
||||
/// </summary>
|
||||
private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List<ChatResponseUpdate> updates)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, session, options, ct) =>
|
||||
{
|
||||
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, cancellationToken: ct))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
private static IChatClient CreateMockChatClient(
|
||||
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
|
||||
{
|
||||
var mock = new Mock<IChatClient>();
|
||||
mock.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetResponse(m, o, ct));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static IChatClient CreateMockStreamingChatClient(
|
||||
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>> onGetStreamingResponse)
|
||||
{
|
||||
var mock = new Mock<IChatClient>();
|
||||
mock.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetStreamingResponse(m, o, ct));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
|
||||
{
|
||||
foreach (var update in updates)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test AIContextProvider that provides configurable messages, tools, and instructions.
|
||||
/// </summary>
|
||||
private sealed class TestAIContextProvider : AIContextProvider
|
||||
{
|
||||
private readonly string _stateKey;
|
||||
private readonly IEnumerable<ChatMessage> _provideMessages;
|
||||
private readonly string? _provideInstructions;
|
||||
private readonly IEnumerable<AITool>? _provideTools;
|
||||
|
||||
public bool InvokedAsyncCalled { get; private set; }
|
||||
|
||||
public InvokedContext? LastInvokedContext { get; private set; }
|
||||
|
||||
public override string StateKey => this._stateKey;
|
||||
|
||||
public TestAIContextProvider(
|
||||
string stateKey,
|
||||
IEnumerable<ChatMessage>? provideMessages = null,
|
||||
string? provideInstructions = null,
|
||||
IEnumerable<AITool>? provideTools = null)
|
||||
{
|
||||
this._stateKey = stateKey;
|
||||
this._provideMessages = provideMessages ?? [];
|
||||
this._provideInstructions = provideInstructions;
|
||||
this._provideTools = provideTools;
|
||||
}
|
||||
|
||||
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Messages = this._provideMessages,
|
||||
Instructions = this._provideInstructions,
|
||||
Tools = this._provideTools,
|
||||
});
|
||||
}
|
||||
|
||||
protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.InvokedAsyncCalled = true;
|
||||
this.LastInvokedContext = context;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal AITool for testing.
|
||||
/// </summary>
|
||||
private sealed class TestAITool : AITool;
|
||||
|
||||
#endregion
|
||||
}
|
||||
+3
-3
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="MessageAIContextProviderAgent"/> class and
|
||||
/// the <see cref="AIAgentBuilder.Use(MessageAIContextProvider[])"/> builder extension.
|
||||
/// the <see cref="AIAgentBuilder.UseAIContextProviders(MessageAIContextProvider[])"/> builder extension.
|
||||
/// </summary>
|
||||
public class MessageAIContextProviderAgentTests
|
||||
{
|
||||
@@ -355,7 +355,7 @@ public class MessageAIContextProviderAgentTests
|
||||
});
|
||||
|
||||
var pipeline = new AIAgentBuilder(innerAgent)
|
||||
.Use([provider])
|
||||
.UseAIContextProviders([provider])
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
@@ -385,7 +385,7 @@ public class MessageAIContextProviderAgentTests
|
||||
});
|
||||
|
||||
var pipeline = new AIAgentBuilder(innerAgent)
|
||||
.Use([provider1, provider2])
|
||||
.UseAIContextProviders([provider1, provider2])
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
@@ -935,6 +935,60 @@ public sealed class FunctionInvocationDelegatingAgentTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Options Preservation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Tests that FunctionInvocationDelegatingAgent preserves all original AgentRunOptions properties
|
||||
/// when converting base AgentRunOptions to ChatClientAgentRunOptions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WithBaseAgentRunOptions_PreservesAllOriginalOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
var responseFormat = ChatResponseFormat.Json;
|
||||
var additionalProperties = new AdditionalPropertiesDictionary { ["key1"] = "value1" };
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
var chatClientAgent = new ChatClientAgent(mockChatClient.Object);
|
||||
|
||||
// Wrap the inner agent in a spy that captures the converted options and returns a dummy response
|
||||
var spyAgent = new AnonymousDelegatingAIAgent(
|
||||
chatClientAgent,
|
||||
runFunc: (messages, session, options, innerAgent, ct) =>
|
||||
{
|
||||
capturedOptions = options;
|
||||
return Task.FromResult(new AgentResponse(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test")) { ResponseId = "test" }));
|
||||
},
|
||||
runStreamingFunc: null);
|
||||
|
||||
static ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
|
||||
=> next(context, cancellationToken);
|
||||
|
||||
var middleware = new FunctionInvocationDelegatingAgent(spyAgent, MiddlewareCallbackAsync);
|
||||
|
||||
var originalOptions = new AgentRunOptions
|
||||
{
|
||||
ResponseFormat = responseFormat,
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
AdditionalProperties = additionalProperties,
|
||||
};
|
||||
|
||||
// Act
|
||||
await middleware.RunAsync([new(ChatRole.User, "Test")], null, originalOptions, CancellationToken.None);
|
||||
|
||||
// Assert - All original properties were preserved on the converted options
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.IsType<ChatClientAgentRunOptions>(capturedOptions);
|
||||
Assert.Same(responseFormat, capturedOptions.ResponseFormat);
|
||||
Assert.True(capturedOptions.AllowBackgroundResponses);
|
||||
Assert.Same(originalOptions.ContinuationToken, capturedOptions.ContinuationToken);
|
||||
Assert.Same(additionalProperties, capturedOptions.AdditionalProperties);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock IChatClient with predefined responses for testing.
|
||||
/// </summary>
|
||||
|
||||
+7
-1
@@ -61,6 +61,11 @@ public abstract class IntegrationTest : IDisposable
|
||||
internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}";
|
||||
|
||||
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false, params IEnumerable<AIFunction> functionTools)
|
||||
{
|
||||
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, functionTools).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, params IEnumerable<AIFunction> functionTools)
|
||||
{
|
||||
AzureAgentProvider agentProvider =
|
||||
new(this.TestEndpoint, new AzureCliCredential())
|
||||
@@ -78,7 +83,8 @@ public abstract class IntegrationTest : IDisposable
|
||||
new DeclarativeWorkflowOptions(agentProvider)
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
LoggerFactory = this.Output
|
||||
LoggerFactory = this.Output,
|
||||
McpToolHandler = mcpToolProvider
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+142
-20
@@ -10,31 +10,48 @@ using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for InvokeFunctionTool action.
|
||||
/// This test pattern can be extended for other InvokeTool types.
|
||||
/// Integration tests for InvokeFunctionTool and InvokeMcpTool actions.
|
||||
/// </summary>
|
||||
public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
|
||||
public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
|
||||
{
|
||||
#region InvokeFunctionTool Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("InvokeFunctionTool.yaml", new string[] { "GetSpecials", "GetItemPrice" }, "2.95")]
|
||||
[InlineData("InvokeFunctionToolWithApproval.yaml", new string[] { "GetItemPrice" }, "4.9")]
|
||||
public Task ValidateInvokeFunctionToolAsync(string workflowFileName, string[] expectedFunctionCalls, string? expectedResultContains) =>
|
||||
this.RunInvokeToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains);
|
||||
this.RunInvokeFunctionToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains);
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokeMcpTool Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("InvokeMcpTool.yaml", "Azure OpenAI")]
|
||||
public Task ValidateInvokeMcpToolAsync(string workflowFileName, string? expectedResultContains) =>
|
||||
this.RunInvokeMcpToolTestAsync(workflowFileName, expectedResultContains, requireApproval: false);
|
||||
|
||||
[Theory]
|
||||
[InlineData("InvokeMcpToolWithApproval.yaml", "Azure OpenAI", true)]
|
||||
[InlineData("InvokeMcpToolWithApproval.yaml", "MCP tool invocation was not approved by user", false)]
|
||||
public Task ValidateInvokeMcpToolWithApprovalAsync(string workflowFileName, string? expectedResultContains, bool approveRequest) =>
|
||||
this.RunInvokeMcpToolTestAsync(workflowFileName, expectedResultContains, requireApproval: true, approveRequest: approveRequest);
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokeFunctionTool Test Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Runs an InvokeTool workflow test with the specified configuration.
|
||||
/// This method is designed to be generic and reusable for different InvokeTool types.
|
||||
/// Runs an InvokeFunctionTool workflow test with the specified configuration.
|
||||
/// </summary>
|
||||
/// <param name="workflowFileName">The workflow YAML file name.</param>
|
||||
/// <param name="expectedFunctionCalls">Expected function names to be called in order.</param>
|
||||
/// <param name="expectedResultContains">Expected text to be present in the final result.</param>
|
||||
private async Task RunInvokeToolTestAsync(
|
||||
private async Task RunInvokeFunctionToolTestAsync(
|
||||
string workflowFileName,
|
||||
string[] expectedFunctionCalls,
|
||||
string? expectedResultContains = null)
|
||||
@@ -72,7 +89,6 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I
|
||||
// Continue processing until there are no more pending input events from the resumed workflow
|
||||
if (resumeEvents.InputEvents.Count == 0)
|
||||
{
|
||||
// No more input events from the last resume - workflow completed
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -85,19 +101,12 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I
|
||||
}
|
||||
|
||||
// Assert - Verify executor and action events
|
||||
Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents);
|
||||
Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents);
|
||||
Assert.NotEmpty(workflowEvents.ActionInvokeEvents);
|
||||
AssertWorkflowEventsEmitted(workflowEvents);
|
||||
|
||||
// Assert - Verify expected result if specified
|
||||
if (expectedResultContains is not null)
|
||||
{
|
||||
MessageActivityEvent? messageEvent = workflowEvents.Events
|
||||
.OfType<MessageActivityEvent>()
|
||||
.LastOrDefault();
|
||||
|
||||
Assert.NotNull(messageEvent);
|
||||
Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase);
|
||||
AssertResultContains(workflowEvents, expectedResultContains);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +159,119 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I
|
||||
return results;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokeMcpTool Test Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Runs an InvokeMcpTool workflow test with the specified configuration.
|
||||
/// </summary>
|
||||
private async Task RunInvokeMcpToolTestAsync(
|
||||
string workflowFileName,
|
||||
string? expectedResultContains = null,
|
||||
bool requireApproval = false,
|
||||
bool approveRequest = true)
|
||||
{
|
||||
// Arrange
|
||||
string workflowPath = GetWorkflowPath(workflowFileName);
|
||||
DefaultMcpToolHandler mcpToolProvider = new();
|
||||
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(
|
||||
externalConversation: false,
|
||||
mcpToolProvider: mcpToolProvider);
|
||||
|
||||
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(workflowPath, workflowOptions);
|
||||
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
|
||||
|
||||
// Act - Run workflow and handle MCP tool invocations
|
||||
WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false);
|
||||
|
||||
while (workflowEvents.InputEvents.Count > 0)
|
||||
{
|
||||
RequestInfoEvent inputEvent = workflowEvents.InputEvents[^1];
|
||||
ExternalInputRequest? toolRequest = inputEvent.Request.Data.As<ExternalInputRequest>();
|
||||
Assert.NotNull(toolRequest);
|
||||
|
||||
IList<AIContent> mcpResults = this.ProcessMcpToolRequests(
|
||||
toolRequest,
|
||||
approveRequest);
|
||||
|
||||
ChatMessage resultMessage = new(ChatRole.Tool, mcpResults);
|
||||
WorkflowEvents resumeEvents = await harness.ResumeAsync(
|
||||
inputEvent.Request.CreateResponse(new ExternalInputResponse(resultMessage))).ConfigureAwait(false);
|
||||
|
||||
workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. resumeEvents.Events]);
|
||||
|
||||
// Continue processing until there are no more pending input events from the resumed workflow
|
||||
if (resumeEvents.InputEvents.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - Verify executor and action events
|
||||
AssertWorkflowEventsEmitted(workflowEvents);
|
||||
|
||||
// Assert - Verify expected result if specified
|
||||
if (expectedResultContains is not null)
|
||||
{
|
||||
AssertResultContains(workflowEvents, expectedResultContains);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await mcpToolProvider.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes MCP tool requests from an external input request.
|
||||
/// Handles approval requests for MCP tools.
|
||||
/// </summary>
|
||||
private List<AIContent> ProcessMcpToolRequests(
|
||||
ExternalInputRequest toolRequest,
|
||||
bool approveRequest)
|
||||
{
|
||||
List<AIContent> results = [];
|
||||
|
||||
foreach (ChatMessage message in toolRequest.AgentResponse.Messages)
|
||||
{
|
||||
// Handle MCP approval requests if present
|
||||
foreach (McpServerToolApprovalRequestContent approvalRequest in message.Contents.OfType<McpServerToolApprovalRequestContent>())
|
||||
{
|
||||
this.Output.WriteLine($"MCP APPROVAL REQUEST: {approvalRequest.Id}");
|
||||
|
||||
// Respond based on test configuration
|
||||
McpServerToolApprovalResponseContent response = approvalRequest.CreateResponse(approved: approveRequest);
|
||||
results.Add(response);
|
||||
|
||||
this.Output.WriteLine($"MCP APPROVAL RESPONSE: {(approveRequest ? "Approved" : "Rejected")}");
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared Helpers
|
||||
|
||||
private static void AssertWorkflowEventsEmitted(WorkflowEvents workflowEvents)
|
||||
{
|
||||
Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents);
|
||||
Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents);
|
||||
Assert.NotEmpty(workflowEvents.ActionInvokeEvents);
|
||||
}
|
||||
|
||||
private static void AssertResultContains(WorkflowEvents workflowEvents, string expectedResultContains)
|
||||
{
|
||||
MessageActivityEvent? messageEvent = workflowEvents.Events
|
||||
.OfType<MessageActivityEvent>()
|
||||
.LastOrDefault();
|
||||
|
||||
Assert.NotNull(messageEvent);
|
||||
Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetWorkflowPath(string workflowFileName) =>
|
||||
Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName);
|
||||
|
||||
#endregion
|
||||
}
|
||||
+1
@@ -10,6 +10,7 @@
|
||||
<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>
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#
|
||||
# This workflow tests invoking MCP tools directly from a workflow.
|
||||
# Uses the Microsoft Learn MCP server: search tool
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_mcp_tool_test
|
||||
actions:
|
||||
|
||||
# Set the search query we want to use
|
||||
- kind: SetVariable
|
||||
id: set_search_query
|
||||
variable: Local.SearchQuery
|
||||
value: Azure OpenAI
|
||||
|
||||
# Invoke MCP search tool on Microsoft Learn server
|
||||
- kind: InvokeMcpTool
|
||||
id: invoke_mcp_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.SearchResult
|
||||
|
||||
# Send the result as an activity
|
||||
- kind: SendMessage
|
||||
id: show_search_result
|
||||
message: "Search results: {Local.SearchResult}"
|
||||
# message: "Search results for {Local.SearchQuery}: {Local.SearchResult}"
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#
|
||||
# This workflow tests invoking MCP tools with approval requirement.
|
||||
# Uses the Microsoft Learn MCP server: search tool with requireApproval: true
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_mcp_tool_approval_test
|
||||
actions:
|
||||
|
||||
# Set the search query we want to use
|
||||
- kind: SetVariable
|
||||
id: set_search_query
|
||||
variable: Local.ContentUrl
|
||||
value: https://learn.microsoft.com/azure/ai-foundry/openai/concepts/use-your-data
|
||||
|
||||
# Invoke MCP search tool with approval requirement
|
||||
- kind: InvokeMcpTool
|
||||
id: invoke_mcp_search
|
||||
serverUrl: https://learn.microsoft.com/api/mcp
|
||||
serverLabel: MicrosoftLearn
|
||||
toolName: microsoft_docs_fetch
|
||||
requireApproval: true
|
||||
arguments:
|
||||
url: =Local.ContentUrl
|
||||
output:
|
||||
autoSend: false
|
||||
result: Local.FetchResult
|
||||
messages: Local.FetchMessages
|
||||
|
||||
# Send the result as an activity
|
||||
- kind: SendMessage
|
||||
id: show_search_result
|
||||
message: "Content for {Local.ContentUrl}: {Local.FetchResult}"
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DefaultMcpToolHandler"/>.
|
||||
/// </summary>
|
||||
public sealed class DefaultMcpToolHandlerTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_WithNoParameters_ShouldCreateInstanceAsync()
|
||||
{
|
||||
// Act
|
||||
DefaultMcpToolHandler handler = new();
|
||||
|
||||
// Assert
|
||||
handler.Should().NotBeNull();
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_WithNullHttpClientProvider_ShouldCreateInstanceAsync()
|
||||
{
|
||||
// Act
|
||||
DefaultMcpToolHandler handler = new(httpClientProvider: null);
|
||||
|
||||
// Assert
|
||||
handler.Should().NotBeNull();
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_WithHttpClientProvider_ShouldCreateInstanceAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<HttpClient?> ProviderAsync(string url, CancellationToken ct) => Task.FromResult<HttpClient?>(new HttpClient());
|
||||
|
||||
// Act
|
||||
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
|
||||
|
||||
// Assert
|
||||
handler.Should().NotBeNull();
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DisposeAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WhenCalled_ShouldCompleteWithoutErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
DefaultMcpToolHandler handler = new();
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await handler.DisposeAsync();
|
||||
|
||||
// Assert
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WhenCalledMultipleTimes_ShouldHandleGracefullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
DefaultMcpToolHandler handler = new();
|
||||
|
||||
// Act
|
||||
await handler.DisposeAsync();
|
||||
Func<Task> act = async () => await handler.DisposeAsync();
|
||||
|
||||
// Assert - Second dispose should throw ObjectDisposedException from the semaphore
|
||||
await act.Should().ThrowAsync<ObjectDisposedException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HttpClientProvider Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeToolAsync_WithHttpClientProvider_ShouldCallProviderAsync()
|
||||
{
|
||||
// Arrange
|
||||
bool providerCalled = false;
|
||||
string? capturedServerUrl = null;
|
||||
|
||||
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
|
||||
{
|
||||
providerCalled = true;
|
||||
capturedServerUrl = url;
|
||||
return Task.FromResult<HttpClient?>(null);
|
||||
}
|
||||
|
||||
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
|
||||
|
||||
// Act & Assert - The call will fail because there's no real MCP server, but the provider should be called
|
||||
try
|
||||
{
|
||||
await handler.InvokeToolAsync(
|
||||
serverUrl: "http://localhost:12345/mcp",
|
||||
serverLabel: "test",
|
||||
toolName: "testTool",
|
||||
arguments: null,
|
||||
headers: null,
|
||||
connectionName: null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected to fail - no real server
|
||||
}
|
||||
finally
|
||||
{
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
|
||||
// Assert
|
||||
providerCalled.Should().BeTrue();
|
||||
capturedServerUrl.Should().Be("http://localhost:12345/mcp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeToolAsync_WithHttpClientProviderReturningClient_ShouldUseProvidedClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
bool providerCalled = false;
|
||||
HttpClient? providedClient = null;
|
||||
|
||||
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
|
||||
{
|
||||
providerCalled = true;
|
||||
providedClient = new HttpClient();
|
||||
return Task.FromResult<HttpClient?>(providedClient);
|
||||
}
|
||||
|
||||
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
|
||||
|
||||
// Act & Assert - The call will fail because there's no real MCP server, but the provider should be called
|
||||
try
|
||||
{
|
||||
await handler.InvokeToolAsync(
|
||||
serverUrl: "http://localhost:12345/mcp",
|
||||
serverLabel: "test",
|
||||
toolName: "testTool",
|
||||
arguments: null,
|
||||
headers: null,
|
||||
connectionName: null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected to fail - no real server
|
||||
}
|
||||
finally
|
||||
{
|
||||
await handler.DisposeAsync();
|
||||
providedClient?.Dispose();
|
||||
}
|
||||
|
||||
// Assert
|
||||
providerCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Caching Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeToolAsync_SameServerUrl_ShouldCallProviderOncePerAttemptWhenConnectionFailsAsync()
|
||||
{
|
||||
// Arrange
|
||||
int providerCallCount = 0;
|
||||
|
||||
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
|
||||
{
|
||||
providerCallCount++;
|
||||
return Task.FromResult<HttpClient?>(null);
|
||||
}
|
||||
|
||||
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
|
||||
const string ServerUrl = "http://localhost:12345/mcp";
|
||||
|
||||
try
|
||||
{
|
||||
// Act - Call twice with the same server URL
|
||||
// Since there's no real server, the McpClient.CreateAsync will fail,
|
||||
// so the client won't be cached and the provider will be called each time
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await handler.InvokeToolAsync(
|
||||
serverUrl: ServerUrl,
|
||||
serverLabel: "test",
|
||||
toolName: "testTool",
|
||||
arguments: null,
|
||||
headers: null,
|
||||
connectionName: null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected to fail - no real server
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - Provider is called each time because McpClient creation fails before caching
|
||||
providerCallCount.Should().Be(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeToolAsync_DifferentServerUrls_ShouldCreateSeparateClientsAsync()
|
||||
{
|
||||
// Arrange
|
||||
int providerCallCount = 0;
|
||||
|
||||
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
|
||||
{
|
||||
providerCallCount++;
|
||||
return Task.FromResult<HttpClient?>(null);
|
||||
}
|
||||
|
||||
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
|
||||
|
||||
try
|
||||
{
|
||||
// Act - Call with different server URLs
|
||||
foreach (string serverUrl in new[] { "http://localhost:12345/mcp1", "http://localhost:12345/mcp2" })
|
||||
{
|
||||
try
|
||||
{
|
||||
await handler.InvokeToolAsync(
|
||||
serverUrl: serverUrl,
|
||||
serverLabel: "test",
|
||||
toolName: "testTool",
|
||||
arguments: null,
|
||||
headers: null,
|
||||
connectionName: null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected to fail - no real server
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - Provider should be called once per unique server URL
|
||||
providerCallCount.Should().Be(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeToolAsync_SameUrlDifferentHeaders_ShouldCreateSeparateClientsAsync()
|
||||
{
|
||||
// Arrange
|
||||
int providerCallCount = 0;
|
||||
|
||||
Task<HttpClient?> ProviderAsync(string url, CancellationToken ct)
|
||||
{
|
||||
providerCallCount++;
|
||||
return Task.FromResult<HttpClient?>(null);
|
||||
}
|
||||
|
||||
DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync);
|
||||
const string ServerUrl = "http://localhost:12345/mcp";
|
||||
|
||||
try
|
||||
{
|
||||
// Act - Call with same URL but different headers
|
||||
Dictionary<string, string>[] headerSets =
|
||||
[
|
||||
new() { ["Authorization"] = "Bearer token1" },
|
||||
new() { ["Authorization"] = "Bearer token2" }
|
||||
];
|
||||
|
||||
foreach (Dictionary<string, string> headers in headerSets)
|
||||
{
|
||||
try
|
||||
{
|
||||
await handler.InvokeToolAsync(
|
||||
serverUrl: ServerUrl,
|
||||
serverLabel: "test",
|
||||
toolName: "testTool",
|
||||
arguments: null,
|
||||
headers: headers,
|
||||
connectionName: null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected to fail - no real server
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - Different headers should create different cache keys
|
||||
providerCallCount.Should().Be(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Interface Implementation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultMcpToolHandler_ShouldImplementIMcpToolHandlerAsync()
|
||||
{
|
||||
// Arrange & Act
|
||||
DefaultMcpToolHandler handler = new();
|
||||
|
||||
// Assert
|
||||
handler.Should().BeAssignableTo<IMcpToolHandler>();
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultMcpToolHandler_ShouldImplementIAsyncDisposableAsync()
|
||||
{
|
||||
// Arrange & Act
|
||||
DefaultMcpToolHandler handler = new();
|
||||
|
||||
// Assert
|
||||
handler.Should().BeAssignableTo<IAsyncDisposable>();
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+279
@@ -384,4 +384,283 @@ public sealed class JsonDocumentExtensionsTests
|
||||
// Act / Assert
|
||||
Assert.Throws<DeclarativeActionException>(() => document.ParseList(typeof(int[])));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for #4195: When a JSON object contains an array of objects
|
||||
/// and is parsed with <c>VariableType.RecordType</c> (no schema), the nested
|
||||
/// object properties must be preserved. Before the fix, DetermineElementType()
|
||||
/// created an empty-schema VariableType, causing ParseRecord to take the
|
||||
/// ParseSchema path (zero fields) and return empty dictionaries.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseRecord_ObjectWithArrayOfObjects_NoSchema_PreservesNestedProperties()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"items": [
|
||||
{ "name": "Alice", "role": "Engineer" },
|
||||
{ "name": "Bob", "role": "Designer" },
|
||||
{ "name": "Carol", "role": "PM" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
Dictionary<string, object?> result = document.ParseRecord(VariableType.RecordType);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ContainsKey("items"));
|
||||
List<object?> items = Assert.IsType<List<object?>>(result["items"]);
|
||||
Assert.Equal(3, items.Count);
|
||||
|
||||
Dictionary<string, object?> first = Assert.IsType<Dictionary<string, object?>>(items[0]);
|
||||
Assert.Equal("Alice", first["name"]);
|
||||
Assert.Equal("Engineer", first["role"]);
|
||||
|
||||
Dictionary<string, object?> second = Assert.IsType<Dictionary<string, object?>>(items[1]);
|
||||
Assert.Equal("Bob", second["name"]);
|
||||
Assert.Equal("Designer", second["role"]);
|
||||
|
||||
Dictionary<string, object?> third = Assert.IsType<Dictionary<string, object?>>(items[2]);
|
||||
Assert.Equal("Carol", third["name"]);
|
||||
Assert.Equal("PM", third["role"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for #4195: When a JSON array of objects is parsed directly
|
||||
/// via <c>ParseList</c> with <c>VariableType.ListType</c> (no schema), all
|
||||
/// object properties must be preserved in each element.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseList_ArrayOfObjects_NoSchema_PreservesProperties()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[
|
||||
{ "name": "Alice", "role": "Engineer" },
|
||||
{ "name": "Bob", "role": "Designer" }
|
||||
]
|
||||
""");
|
||||
|
||||
// Act
|
||||
List<object?> result = document.ParseList(VariableType.ListType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
|
||||
Dictionary<string, object?> first = Assert.IsType<Dictionary<string, object?>>(result[0]);
|
||||
Assert.Equal("Alice", first["name"]);
|
||||
Assert.Equal("Engineer", first["role"]);
|
||||
|
||||
Dictionary<string, object?> second = Assert.IsType<Dictionary<string, object?>>(result[1]);
|
||||
Assert.Equal("Bob", second["name"]);
|
||||
Assert.Equal("Designer", second["role"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_EmptyArray_ReturnsFallbackListType()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse("[]");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(VariableType.ListType, result.Type);
|
||||
Assert.False(result.HasSchema);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_ArrayOfPrimitives_ReturnsFallbackListType()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse("[1, 2, 3]");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(VariableType.ListType, result.Type);
|
||||
Assert.False(result.HasSchema);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_ObjectWithStringField_InfersStringType()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[{ "name": "hello" }]
|
||||
""");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasSchema);
|
||||
Assert.True(result.Schema!.ContainsKey("name"));
|
||||
Assert.Equal(typeof(string), result.Schema["name"].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_ObjectWithNumberField_InfersDecimalType()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[{ "value": 42 }]
|
||||
""");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasSchema);
|
||||
Assert.True(result.Schema!.ContainsKey("value"));
|
||||
Assert.Equal(typeof(decimal), result.Schema["value"].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_ObjectWithBooleanTrueField_InfersBoolType()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[{ "flag": true }]
|
||||
""");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasSchema);
|
||||
Assert.True(result.Schema!.ContainsKey("flag"));
|
||||
Assert.Equal(typeof(bool), result.Schema["flag"].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_ObjectWithBooleanFalseField_InfersBoolType()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[{ "flag": false }]
|
||||
""");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasSchema);
|
||||
Assert.True(result.Schema!.ContainsKey("flag"));
|
||||
Assert.Equal(typeof(bool), result.Schema["flag"].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_ObjectWithNestedObjectField_InfersRecordType()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[{ "child": { "inner": 1 } }]
|
||||
""");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasSchema);
|
||||
Assert.True(result.Schema!.ContainsKey("child"));
|
||||
Assert.Equal(VariableType.RecordType, result.Schema["child"].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_ObjectWithNestedArrayField_InfersListType()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[{ "items": [1, 2, 3] }]
|
||||
""");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasSchema);
|
||||
Assert.True(result.Schema!.ContainsKey("items"));
|
||||
Assert.Equal(VariableType.ListType, result.Schema["items"].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_ObjectWithNullField_InfersStringTypeDefault()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[{ "missing": null }]
|
||||
""");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasSchema);
|
||||
Assert.True(result.Schema!.ContainsKey("missing"));
|
||||
Assert.Equal(typeof(string), result.Schema["missing"].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_SkipsNonObjectElements_InfersFromFirstObject()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[1, "text", { "id": 99 }]
|
||||
""");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasSchema);
|
||||
Assert.True(result.Schema!.ContainsKey("id"));
|
||||
Assert.Equal(typeof(decimal), result.Schema["id"].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetListTypeFromJson_ObjectWithAllFieldTypes_InfersCorrectTypes()
|
||||
{
|
||||
// Arrange
|
||||
JsonDocument document = JsonDocument.Parse(
|
||||
"""
|
||||
[{
|
||||
"text": "hello",
|
||||
"count": 5,
|
||||
"enabled": true,
|
||||
"disabled": false,
|
||||
"nested": { "x": 1 },
|
||||
"list": [1, 2],
|
||||
"empty": null
|
||||
}]
|
||||
""");
|
||||
|
||||
// Act
|
||||
VariableType result = document.RootElement.GetListTypeFromJson();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasSchema);
|
||||
Assert.Equal(7, result.Schema!.Count);
|
||||
Assert.Equal(typeof(string), result.Schema["text"].Type);
|
||||
Assert.Equal(typeof(decimal), result.Schema["count"].Type);
|
||||
Assert.Equal(typeof(bool), result.Schema["enabled"].Type);
|
||||
Assert.Equal(typeof(bool), result.Schema["disabled"].Type);
|
||||
Assert.Equal(VariableType.RecordType, result.Schema["nested"].Type);
|
||||
Assert.Equal(VariableType.ListType, result.Schema["list"].Type);
|
||||
Assert.Equal(typeof(string), result.Schema["empty"].Type);
|
||||
}
|
||||
}
|
||||
|
||||
+845
@@ -0,0 +1,845 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="InvokeMcpToolExecutor"/>.
|
||||
/// </summary>
|
||||
public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
|
||||
{
|
||||
private const string TestServerUrl = "https://mcp.example.com";
|
||||
private const string TestServerLabel = "TestMcpServer";
|
||||
private const string TestToolName = "test_tool";
|
||||
|
||||
#region Step Naming Convention Tests
|
||||
|
||||
[Fact]
|
||||
public void InvokeMcpToolThrowsWhenModelInvalid()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<DeclarativeModelException>(() => new InvokeMcpToolExecutor(
|
||||
new InvokeMcpTool(),
|
||||
mockProvider.Object,
|
||||
mockAgentProvider.Object,
|
||||
this.State));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokeMcpToolNamingConvention()
|
||||
{
|
||||
// Arrange
|
||||
string testId = this.CreateActionId().Value;
|
||||
|
||||
// Act
|
||||
string externalInputStep = InvokeMcpToolExecutor.Steps.ExternalInput(testId);
|
||||
string resumeStep = InvokeMcpToolExecutor.Steps.Resume(testId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal($"{testId}_{nameof(InvokeMcpToolExecutor.Steps.ExternalInput)}", externalInputStep);
|
||||
Assert.Equal($"{testId}_{nameof(InvokeMcpToolExecutor.Steps.Resume)}", resumeStep);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RequiresInput and RequiresNothing Tests
|
||||
|
||||
[Fact]
|
||||
public void RequiresInputReturnsTrueForExternalInputRequest()
|
||||
{
|
||||
// Arrange
|
||||
ExternalInputRequest request = new(new AgentResponse([]));
|
||||
|
||||
// Act
|
||||
bool result = InvokeMcpToolExecutor.RequiresInput(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresInputReturnsFalseForOtherTypes()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.False(InvokeMcpToolExecutor.RequiresInput(null));
|
||||
Assert.False(InvokeMcpToolExecutor.RequiresInput("string"));
|
||||
Assert.False(InvokeMcpToolExecutor.RequiresInput(new ActionExecutorResult("test")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresNothingReturnsTrueForActionExecutorResult()
|
||||
{
|
||||
// Arrange
|
||||
ActionExecutorResult result = new("test");
|
||||
|
||||
// Act
|
||||
bool requiresNothing = InvokeMcpToolExecutor.RequiresNothing(result);
|
||||
|
||||
// Assert
|
||||
Assert.True(requiresNothing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresNothingReturnsFalseForOtherTypes()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.False(InvokeMcpToolExecutor.RequiresNothing(null));
|
||||
Assert.False(InvokeMcpToolExecutor.RequiresNothing("string"));
|
||||
Assert.False(InvokeMcpToolExecutor.RequiresNothing(new ExternalInputRequest(new AgentResponse([]))));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithoutApprovalAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithoutApprovalAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
requireApproval: false);
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithServerLabelAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithServerLabelAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
serverLabel: TestServerLabel,
|
||||
toolName: TestToolName);
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithArgumentsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithArgumentsAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
argumentKey: "query",
|
||||
argumentValue: "test query");
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithHeadersAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
headerKey: "Authorization",
|
||||
headerValue: "Bearer token123");
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithRequireApprovalAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithRequireApprovalAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
requireApproval: true);
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithEmptyConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithEmptyConversationIdAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
conversationId: "");
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithNullArgumentsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithNullArgumentsAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
argumentKey: null);
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithNullRequireApprovalAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithNullRequireApprovalAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
requireApproval: null);
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithNullConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithNullConversationIdAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
conversationId: null);
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithEmptyServerLabelAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithEmptyServerLabelAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
serverLabel: "",
|
||||
toolName: TestToolName);
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithConversationIdAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
conversationId: "test-conversation-id");
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithRequireApprovalAndHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithRequireApprovalAndHeadersAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
requireApproval: true,
|
||||
headerKey: "X-Custom-Header",
|
||||
headerValue: "custom-value");
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithEmptyHeaderValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithEmptyHeaderValueAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
headerKey: "X-Empty-Header",
|
||||
headerValue: "");
|
||||
|
||||
// Act and Assert
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithJsonObjectResultAsync()
|
||||
{
|
||||
// Arrange - Tests JSON object parsing in AssignResultAsync
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithJsonObjectResultAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new(returnJsonObject: true);
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithJsonArrayResultAsync()
|
||||
{
|
||||
// Arrange - Tests JSON array parsing in AssignResultAsync
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithJsonArrayResultAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new(returnJsonArray: true);
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithInvalidJsonResultAsync()
|
||||
{
|
||||
// Arrange - Tests graceful handling of invalid JSON
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithInvalidJsonResultAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new(returnInvalidJson: true);
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert - Should handle gracefully
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithDataContentResultAsync()
|
||||
{
|
||||
// Arrange - Tests DataContent handling (returns URI)
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithDataContentResultAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new(returnDataContent: true);
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithEmptyOutputAsync()
|
||||
{
|
||||
// Arrange - Tests empty output list handling
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithEmptyOutputAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new(returnEmptyOutput: true);
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithNullOutputAsync()
|
||||
{
|
||||
// Arrange - Tests null output handling
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithNullOutputAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new(returnNullOutput: true);
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync()
|
||||
{
|
||||
// Arrange - Tests handling of multiple content types in output
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithMultipleContentTypesAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new(returnMultipleContent: true);
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CaptureResponseAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseWithApprovalApprovedAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovalApprovedAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
requireApproval: true);
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Create approval request then response
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
|
||||
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
Assert.NotEmpty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseWithApprovalRejectedAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovalRejectedAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
requireApproval: true);
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Create approval request then response (rejected)
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
|
||||
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: false);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
Assert.NotEmpty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseWithEmptyMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseWithEmptyMessagesAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Empty response - no approval found, should treat as rejected
|
||||
ExternalInputResponse response = new([]);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
Assert.NotEmpty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseWithNonMatchingApprovalIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseWithNonMatchingApprovalIdAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Create approval with different ID
|
||||
McpServerToolCallContent toolCall = new("different_id", TestToolName, TestServerUrl);
|
||||
McpServerToolApprovalRequestContent approvalRequest = new("different_id", toolCall);
|
||||
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
|
||||
|
||||
// Assert - Should be treated as rejected since no matching approval
|
||||
VerifyModel(model, action);
|
||||
Assert.NotEmpty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseWithApprovedAndArgumentsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovedAndArgumentsAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
requireApproval: true,
|
||||
argumentKey: "query",
|
||||
argumentValue: "test query");
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Create approval request then response
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
|
||||
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
Assert.NotEmpty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseWithApprovedAndHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovedAndHeadersAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
serverLabel: TestServerLabel,
|
||||
toolName: TestToolName,
|
||||
requireApproval: true,
|
||||
headerKey: "X-Custom-Header",
|
||||
headerValue: "custom-value");
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Create approval request then response
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerLabel);
|
||||
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
Assert.NotEmpty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseWithApprovedAndConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string ConversationId = "TestConversationId";
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseWithApprovedAndConversationIdAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
requireApproval: true,
|
||||
conversationId: ConversationId);
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Create approval request then response
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
|
||||
McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
Assert.NotEmpty(events);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CompleteAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCompleteAsyncRaisesCompletionEventAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolCompleteAsyncRaisesCompletionEventAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName);
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
ActionExecutorResult result = new(action.Id);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteCompleteTestAsync(action, result);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
Assert.NotEmpty(events);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task ExecuteTestAsync(InvokeMcpTool model)
|
||||
{
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
|
||||
// IsDiscreteAction should be false for InvokeMcpTool
|
||||
VerifyIsDiscrete(action, isDiscrete: false);
|
||||
}
|
||||
|
||||
private async Task<WorkflowEvent[]> ExecuteCaptureResponseTestAsync(
|
||||
InvokeMcpToolExecutor action,
|
||||
ExternalInputResponse response)
|
||||
{
|
||||
return await this.ExecuteAsync(
|
||||
action,
|
||||
InvokeMcpToolExecutor.Steps.ExternalInput(action.Id),
|
||||
(context, _, cancellationToken) => action.CaptureResponseAsync(context, response, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<WorkflowEvent[]> ExecuteCompleteTestAsync(
|
||||
InvokeMcpToolExecutor action,
|
||||
ActionExecutorResult result)
|
||||
{
|
||||
return await this.ExecuteAsync(
|
||||
action,
|
||||
InvokeMcpToolExecutor.Steps.Resume(action.Id),
|
||||
(context, _, cancellationToken) => action.CompleteAsync(context, result, cancellationToken));
|
||||
}
|
||||
|
||||
private InvokeMcpTool CreateModel(
|
||||
string displayName,
|
||||
string serverUrl,
|
||||
string toolName,
|
||||
string? serverLabel = null,
|
||||
bool? requireApproval = false,
|
||||
string? conversationId = null,
|
||||
string? argumentKey = null,
|
||||
string? argumentValue = null,
|
||||
string? headerKey = null,
|
||||
string? headerValue = null)
|
||||
{
|
||||
InvokeMcpTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
ServerUrl = new StringExpression.Builder(StringExpression.Literal(serverUrl)),
|
||||
ToolName = new StringExpression.Builder(StringExpression.Literal(toolName)),
|
||||
RequireApproval = requireApproval != null ? new BoolExpression.Builder(BoolExpression.Literal(requireApproval.Value)) : null
|
||||
};
|
||||
|
||||
if (serverLabel is not null)
|
||||
{
|
||||
builder.ServerLabel = new StringExpression.Builder(StringExpression.Literal(serverLabel));
|
||||
}
|
||||
|
||||
if (conversationId is not null)
|
||||
{
|
||||
builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId));
|
||||
}
|
||||
|
||||
if (argumentKey is not null && argumentValue is not null)
|
||||
{
|
||||
builder.Arguments.Add(argumentKey, ValueExpression.Literal(new StringDataValue(argumentValue)));
|
||||
}
|
||||
|
||||
if (headerKey is not null && headerValue is not null)
|
||||
{
|
||||
builder.Headers.Add(headerKey, new StringExpression.Builder(StringExpression.Literal(headerValue)));
|
||||
}
|
||||
|
||||
return AssignParent<InvokeMcpTool>(builder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mock MCP Tool Provider
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of <see cref="IMcpToolHandler"/> for unit testing purposes.
|
||||
/// </summary>
|
||||
private sealed class MockMcpToolProvider : Mock<IMcpToolHandler>
|
||||
{
|
||||
public MockMcpToolProvider(
|
||||
bool returnJsonObject = false,
|
||||
bool returnJsonArray = false,
|
||||
bool returnInvalidJson = false,
|
||||
bool returnDataContent = false,
|
||||
bool returnEmptyOutput = false,
|
||||
bool returnNullOutput = false,
|
||||
bool returnMultipleContent = false)
|
||||
{
|
||||
this.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, _, _, _, _, _) =>
|
||||
{
|
||||
McpServerToolResultContent result = new("mock-call-id");
|
||||
|
||||
if (returnNullOutput)
|
||||
{
|
||||
result.Output = null;
|
||||
}
|
||||
else if (returnEmptyOutput)
|
||||
{
|
||||
result.Output = [];
|
||||
}
|
||||
else if (returnJsonObject)
|
||||
{
|
||||
result.Output = [new TextContent("{\"key\": \"value\", \"number\": 42}")];
|
||||
}
|
||||
else if (returnJsonArray)
|
||||
{
|
||||
result.Output = [new TextContent("[1, 2, 3, \"four\"]")];
|
||||
}
|
||||
else if (returnInvalidJson)
|
||||
{
|
||||
result.Output = [new TextContent("this is not valid json {")];
|
||||
}
|
||||
else if (returnDataContent)
|
||||
{
|
||||
result.Output = [new DataContent("data:image/png;base64,iVBORw0KGgo=", "image/png")];
|
||||
}
|
||||
else if (returnMultipleContent)
|
||||
{
|
||||
result.Output =
|
||||
[
|
||||
new TextContent("First text"),
|
||||
new TextContent("{\"nested\": true}"),
|
||||
new DataContent("data:audio/mp3;base64,SUQz", "audio/mp3")
|
||||
];
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Output = [new TextContent("Mock MCP tool result")];
|
||||
}
|
||||
|
||||
return Task.FromResult(result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -43,15 +43,16 @@ public sealed class ObservabilityTests : IDisposable
|
||||
/// Create a sample workflow for testing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This workflow is expected to create 8 activities that will be captured by the tests
|
||||
/// This workflow is expected to create 9 activities that will be captured by the tests
|
||||
/// - ActivityNames.WorkflowBuild
|
||||
/// - ActivityNames.WorkflowRun
|
||||
/// -- ActivityNames.EdgeGroupProcess
|
||||
/// -- ActivityNames.ExecutorProcess (UppercaseExecutor)
|
||||
/// --- ActivityNames.MessageSend
|
||||
/// ---- ActivityNames.EdgeGroupProcess
|
||||
/// -- ActivityNames.ExecutorProcess (ReverseTextExecutor)
|
||||
/// --- ActivityNames.MessageSend
|
||||
/// - ActivityNames.WorkflowSession
|
||||
/// -- ActivityNames.WorkflowInvoke
|
||||
/// --- ActivityNames.EdgeGroupProcess
|
||||
/// --- ActivityNames.ExecutorProcess (UppercaseExecutor)
|
||||
/// ---- ActivityNames.MessageSend
|
||||
/// ----- ActivityNames.EdgeGroupProcess
|
||||
/// --- ActivityNames.ExecutorProcess (ReverseTextExecutor)
|
||||
/// ---- ActivityNames.MessageSend
|
||||
/// </remarks>
|
||||
/// <returns>The created workflow.</returns>
|
||||
private static Workflow CreateWorkflow()
|
||||
@@ -74,7 +75,8 @@ public sealed class ObservabilityTests : IDisposable
|
||||
new()
|
||||
{
|
||||
{ ActivityNames.WorkflowBuild, 1 },
|
||||
{ ActivityNames.WorkflowRun, 1 },
|
||||
{ ActivityNames.WorkflowSession, 1 },
|
||||
{ ActivityNames.WorkflowInvoke, 1 },
|
||||
{ ActivityNames.EdgeGroupProcess, 2 },
|
||||
{ ActivityNames.ExecutorProcess, 2 },
|
||||
{ ActivityNames.MessageSend, 2 }
|
||||
@@ -113,7 +115,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList();
|
||||
capturedActivities.Should().HaveCount(8, "Exactly 8 activities should be created.");
|
||||
capturedActivities.Should().HaveCount(9, "Exactly 9 activities should be created.");
|
||||
|
||||
// Make sure all expected activities exist and have the correct count
|
||||
foreach (var kvp in GetExpectedActivityNameCounts())
|
||||
@@ -125,7 +127,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
}
|
||||
|
||||
// Verify WorkflowRun activity events include workflow lifecycle events
|
||||
var workflowRunActivity = capturedActivities.First(a => a.OperationName.StartsWith(ActivityNames.WorkflowRun, StringComparison.Ordinal));
|
||||
var workflowRunActivity = capturedActivities.First(a => a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal));
|
||||
var activityEvents = workflowRunActivity.Events.ToList();
|
||||
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowStarted, "activity should have workflow started event");
|
||||
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event");
|
||||
@@ -273,8 +275,11 @@ public sealed class ObservabilityTests : IDisposable
|
||||
// Assert
|
||||
var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList();
|
||||
capturedActivities.Should().NotContain(
|
||||
a => a.OperationName.StartsWith(ActivityNames.WorkflowRun, StringComparison.Ordinal),
|
||||
a => a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal),
|
||||
"WorkflowRun activity should be disabled.");
|
||||
capturedActivities.Should().NotContain(
|
||||
a => a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal),
|
||||
"WorkflowSession activity should also be disabled when DisableWorkflowRun is true.");
|
||||
capturedActivities.Should().Contain(
|
||||
a => a.OperationName.StartsWith(ActivityNames.WorkflowBuild, StringComparison.Ordinal),
|
||||
"Other activities should still be created.");
|
||||
@@ -303,7 +308,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
a => a.OperationName.StartsWith(ActivityNames.ExecutorProcess, StringComparison.Ordinal),
|
||||
"ExecutorProcess activity should be disabled.");
|
||||
capturedActivities.Should().Contain(
|
||||
a => a.OperationName.StartsWith(ActivityNames.WorkflowRun, StringComparison.Ordinal),
|
||||
a => a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal),
|
||||
"Other activities should still be created.");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Observability;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for https://github.com/microsoft/agent-framework/issues/4155
|
||||
/// Verifies that the workflow_invoke Activity is properly stopped/disposed so it gets exported
|
||||
/// to telemetry backends. The ActivityStopped callback must fire for the workflow_invoke span.
|
||||
/// </summary>
|
||||
[Collection("ObservabilityTests")]
|
||||
public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
{
|
||||
private readonly ActivityListener _activityListener;
|
||||
private readonly ConcurrentBag<Activity> _startedActivities = [];
|
||||
private readonly ConcurrentBag<Activity> _stoppedActivities = [];
|
||||
private bool _isDisposed;
|
||||
|
||||
public WorkflowRunActivityStopTests()
|
||||
{
|
||||
this._activityListener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => source.Name.Contains(typeof(Workflow).Namespace!),
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllData,
|
||||
ActivityStarted = activity => this._startedActivities.Add(activity),
|
||||
ActivityStopped = activity => this._stoppedActivities.Add(activity),
|
||||
};
|
||||
ActivitySource.AddActivityListener(this._activityListener);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!this._isDisposed)
|
||||
{
|
||||
this._activityListener?.Dispose();
|
||||
this._isDisposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a simple sequential workflow with OpenTelemetry enabled.
|
||||
/// </summary>
|
||||
private static Workflow CreateWorkflow()
|
||||
{
|
||||
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
|
||||
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
|
||||
|
||||
Func<string, string> reverseFunc = s => new string(s.Reverse().ToArray());
|
||||
var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor");
|
||||
|
||||
WorkflowBuilder builder = new(uppercase);
|
||||
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
|
||||
|
||||
return builder.WithOpenTelemetry().Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the workflow_invoke Activity is stopped (and thus exportable) when
|
||||
/// using the Lockstep execution environment.
|
||||
/// Bug: The Activity created by LockstepRunEventStream.TakeEventStreamAsync is never
|
||||
/// disposed because yield break in async iterators does not trigger using disposal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_LockstepAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var testActivity = new Activity("WorkflowRunStopTest_Lockstep").Start();
|
||||
|
||||
// Act
|
||||
var workflow = CreateWorkflow();
|
||||
Run run = await InProcessExecution.Lockstep.RunAsync(workflow, "Hello, World!");
|
||||
await run.DisposeAsync();
|
||||
|
||||
// Assert - workflow.session should have been started and stopped
|
||||
var startedSessions = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
startedSessions.Should().HaveCount(1, "workflow.session Activity should be started");
|
||||
|
||||
var stoppedSessions = this._stoppedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
stoppedSessions.Should().HaveCount(1,
|
||||
"workflow.session Activity should be stopped/disposed so it is exported to telemetry backends");
|
||||
|
||||
// Assert - workflow_invoke should have been started and stopped
|
||||
var startedWorkflowRuns = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
startedWorkflowRuns.Should().HaveCount(1, "workflow_invoke Activity should be started");
|
||||
|
||||
var stoppedWorkflowRuns = this._stoppedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
stoppedWorkflowRuns.Should().HaveCount(1,
|
||||
"workflow_invoke Activity should be stopped/disposed so it is exported to telemetry backends (issue #4155)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the workflow_invoke Activity is stopped when using the OffThread (Default)
|
||||
/// execution environment (StreamingRunEventStream).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_OffThreadAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var testActivity = new Activity("WorkflowRunStopTest_OffThread").Start();
|
||||
|
||||
// Act
|
||||
var workflow = CreateWorkflow();
|
||||
Run run = await InProcessExecution.OffThread.RunAsync(workflow, "Hello, World!");
|
||||
await run.DisposeAsync();
|
||||
|
||||
// Assert - workflow.session should have been started and stopped
|
||||
var startedSessions = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
startedSessions.Should().HaveCount(1, "workflow.session Activity should be started");
|
||||
|
||||
var stoppedSessions = this._stoppedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
stoppedSessions.Should().HaveCount(1,
|
||||
"workflow.session Activity should be stopped/disposed so it is exported to telemetry backends");
|
||||
|
||||
// Assert - workflow_invoke should have been started and stopped
|
||||
var startedWorkflowRuns = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
startedWorkflowRuns.Should().HaveCount(1, "workflow_invoke Activity should be started");
|
||||
|
||||
var stoppedWorkflowRuns = this._stoppedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
stoppedWorkflowRuns.Should().HaveCount(1,
|
||||
"workflow_invoke Activity should be stopped/disposed so it is exported to telemetry backends (issue #4155)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the workflow_invoke Activity is stopped when using the streaming API
|
||||
/// (StreamingRun.WatchStreamAsync) with the OffThread execution environment.
|
||||
/// This matches the exact usage pattern described in the issue.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThreadAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var testActivity = new Activity("WorkflowRunStopTest_Streaming_OffThread").Start();
|
||||
|
||||
// Act - use streaming path (WatchStreamAsync), which is the pattern from the issue
|
||||
var workflow = CreateWorkflow();
|
||||
StreamingRun run = await InProcessExecution.OffThread.RunStreamingAsync(workflow, "Hello, World!");
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
// Consume all events
|
||||
}
|
||||
|
||||
// Dispose the run before asserting — the run Activity is disposed when the
|
||||
// run loop exits, which happens during DisposeAsync. Without this, assertions
|
||||
// can race against the background run loop's finally block.
|
||||
await run.DisposeAsync();
|
||||
|
||||
// Assert - workflow.session should have been started
|
||||
var startedSessions = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
startedSessions.Should().HaveCount(1, "workflow.session Activity should be started");
|
||||
|
||||
// Assert - workflow_invoke should have been started
|
||||
var startedWorkflowRuns = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
startedWorkflowRuns.Should().HaveCount(1, "workflow_invoke Activity should be started");
|
||||
|
||||
// Assert - workflow_invoke should have been stopped
|
||||
var stoppedWorkflowRuns = this._stoppedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
stoppedWorkflowRuns.Should().HaveCount(1,
|
||||
"workflow_invoke Activity should be stopped/disposed so it is exported to telemetry backends (issue #4155)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a new workflow_invoke activity is started and stopped for each
|
||||
/// streaming invocation, even when using the same workflow in a multi-turn pattern,
|
||||
/// and that each session gets its own session activity.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThread_MultiTurnAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var testActivity = new Activity("WorkflowRunStopTest_Streaming_OffThread_MultiTurn").Start();
|
||||
|
||||
var workflow = CreateWorkflow();
|
||||
|
||||
// Act - first streaming run
|
||||
await using (StreamingRun run1 = await InProcessExecution.OffThread.RunStreamingAsync(workflow, "Hello, World!"))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in run1.WatchStreamAsync())
|
||||
{
|
||||
// Consume all events from first turn
|
||||
}
|
||||
}
|
||||
|
||||
// Act - second streaming run (multi-turn scenario with same workflow)
|
||||
await using (StreamingRun run2 = await InProcessExecution.OffThread.RunStreamingAsync(workflow, "Second turn!"))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in run2.WatchStreamAsync())
|
||||
{
|
||||
// Consume all events from second turn
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - two workflow.session activities should have been started and stopped
|
||||
var startedSessions = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
startedSessions.Should().HaveCount(2,
|
||||
"each streaming invocation should start its own workflow.session Activity");
|
||||
|
||||
var stoppedSessions = this._stoppedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
stoppedSessions.Should().HaveCount(2,
|
||||
"each workflow.session Activity should be stopped/disposed so it is exported to telemetry backends");
|
||||
|
||||
// Assert - two workflow_invoke activities should have been started and stopped
|
||||
var startedWorkflowRuns = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
startedWorkflowRuns.Should().HaveCount(2,
|
||||
"each streaming invocation should start its own workflow_invoke Activity");
|
||||
|
||||
var stoppedWorkflowRuns = this._stoppedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
stoppedWorkflowRuns.Should().HaveCount(2,
|
||||
"each workflow_invoke Activity should be stopped/disposed so it is exported to telemetry backends in multi-turn scenarios");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all started activities (not just workflow_invoke) are properly stopped.
|
||||
/// This ensures no spans are "leaked" without being exported.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AllActivities_AreStopped_AfterWorkflowCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var testActivity = new Activity("AllActivitiesStopTest").Start();
|
||||
|
||||
// Act
|
||||
var workflow = CreateWorkflow();
|
||||
Run run = await InProcessExecution.Lockstep.RunAsync(workflow, "Hello, World!");
|
||||
await run.DisposeAsync();
|
||||
|
||||
// Assert - every started activity should also be stopped
|
||||
var started = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId)
|
||||
.Select(a => a.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var stopped = this._stoppedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId)
|
||||
.Select(a => a.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var neverStopped = started.Except(stopped).ToList();
|
||||
if (neverStopped.Count > 0)
|
||||
{
|
||||
var neverStoppedNames = this._startedActivities
|
||||
.Where(a => neverStopped.Contains(a.Id))
|
||||
.Select(a => a.OperationName)
|
||||
.ToList();
|
||||
neverStoppedNames.Should().BeEmpty(
|
||||
"all started activities should be stopped so they are exported. " +
|
||||
$"Activities started but never stopped: [{string.Join(", ", neverStoppedNames)}]");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Activity.Current is not leaked after lockstep RunAsync.
|
||||
/// Application code creating activities after RunAsync returns should not
|
||||
/// be parented under the workflow session span. The run activity should
|
||||
/// still nest correctly under the session.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Lockstep_SessionActivity_DoesNotLeak_IntoCaller_ActivityCurrentAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var testActivity = new Activity("SessionLeakTest").Start();
|
||||
var workflow = CreateWorkflow();
|
||||
|
||||
// Act — run the workflow via lockstep (Start + drain happen inside RunAsync)
|
||||
Run run = await InProcessExecution.Lockstep.RunAsync(workflow, "Hello, World!");
|
||||
|
||||
// Create an application activity after RunAsync returns.
|
||||
// If the session leaked into Activity.Current, this would be parented under it.
|
||||
using var appActivity = new Activity("AppWork").Start();
|
||||
appActivity.Stop();
|
||||
|
||||
await run.DisposeAsync();
|
||||
|
||||
// Assert — the app activity should be parented under the test root, not the session
|
||||
var sessionActivities = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
sessionActivities.Should().HaveCount(1, "one session activity should exist");
|
||||
|
||||
appActivity.ParentId.Should().Be(testActivity.Id,
|
||||
"application activity should be parented under the test root, not the workflow session");
|
||||
|
||||
// Assert — the run activity should still be parented under the session
|
||||
var invokeActivities = this._startedActivities
|
||||
.Where(a => a.RootId == testActivity.RootId &&
|
||||
a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
invokeActivities.Should().HaveCount(1, "one workflow_invoke activity should exist");
|
||||
invokeActivities[0].ParentId.Should().Be(sessionActivities[0].Id,
|
||||
"workflow_invoke activity should be nested under the session activity");
|
||||
}
|
||||
}
|
||||
+70
-4
@@ -9,7 +9,7 @@ description: >
|
||||
|
||||
We strive for at least 85% test coverage across the codebase, with a focus on core packages and critical paths. Tests should be fast, reliable, and maintainable.
|
||||
When adding new code, check that the relevant sections of the codebase are covered by tests, and add new tests as needed. When modifying existing code, update or add tests to cover the changes.
|
||||
We run tests in two stages, for a PR each commit is tested with `RUN_INTEGRATION_TESTS=false` (unit tests only), and the full suite with `RUN_INTEGRATION_TESTS=true` is run when merging.
|
||||
We run tests in two stages, for a PR each commit is tested with unit tests only (using `-m "not integration"`), and the full suite including integration tests is run when merging.
|
||||
|
||||
## Running Tests
|
||||
|
||||
@@ -25,6 +25,12 @@ uv run poe all-tests
|
||||
|
||||
# With coverage
|
||||
uv run poe all-tests-cov
|
||||
|
||||
# Run only unit tests (exclude integration tests)
|
||||
uv run poe all-tests -m "not integration"
|
||||
|
||||
# Run only integration tests
|
||||
uv run poe all-tests -m integration
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
@@ -32,6 +38,7 @@ uv run poe all-tests-cov
|
||||
- **Async mode**: `asyncio_mode = "auto"` is enabled — do NOT use `@pytest.mark.asyncio`, but do mark tests with `async def` and use `await` for async calls
|
||||
- **Timeout**: Default 60 seconds per test
|
||||
- **Import mode**: `importlib` for cross-package isolation
|
||||
- **Parallelization**: Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` (`-n auto --dist worksteal`) in their `poe test` task. The `all-tests` task also uses xdist across all packages.
|
||||
|
||||
## Test Directory Structure
|
||||
|
||||
@@ -72,9 +79,68 @@ packages/core/
|
||||
|
||||
## Integration Tests
|
||||
|
||||
Tests marked with `@skip_if_..._integration_tests_disabled` require:
|
||||
- `RUN_INTEGRATION_TESTS=true` environment variable
|
||||
- Appropriate API keys in environment or `.env` file
|
||||
Integration tests require external services (OpenAI, Azure, etc.) and are controlled by three markers:
|
||||
|
||||
1. **`@pytest.mark.flaky`** — marks the test as potentially flaky since it depends on external services
|
||||
2. **`@pytest.mark.integration`** — used for test selection, so integration tests can be included/excluded with `-m integration` / `-m "not integration"`
|
||||
3. **`@skip_if_..._integration_tests_disabled`** decorator — skips the test when the required API keys or service endpoints are missing
|
||||
|
||||
### Adding New Integration Tests
|
||||
|
||||
All three markers must be applied to every new integration test:
|
||||
|
||||
```python
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_completion() -> None:
|
||||
...
|
||||
```
|
||||
|
||||
For test files where all tests are integration tests (e.g., Azure Functions, Durable Task), use the module-level `pytestmark` list:
|
||||
|
||||
```python
|
||||
pytestmark = [
|
||||
pytest.mark.flaky,
|
||||
pytest.mark.integration,
|
||||
pytest.mark.sample("01_single_agent"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
]
|
||||
```
|
||||
|
||||
### CI Workflow
|
||||
|
||||
The merge CI workflow (`python-merge-tests.yml`) splits integration tests into parallel jobs by provider with change-based detection:
|
||||
|
||||
- **Unit tests** — always run all non-integration tests
|
||||
- **OpenAI integration** — runs when `packages/core/agent_framework/openai/` or core infrastructure changes
|
||||
- **Azure OpenAI integration** — runs when `packages/core/agent_framework/azure/` or core changes
|
||||
- **Misc integration** — Anthropic, Ollama, MCP tests; runs when their packages or core change
|
||||
- **Functions integration** — Azure Functions + Durable Task; runs when their packages or core change
|
||||
- **Azure AI integration** — runs when `packages/azure-ai/` or core changes
|
||||
|
||||
Core infrastructure changes (e.g., `_agents.py`, `_types.py`) trigger all integration test jobs. Scheduled and manual runs always execute all jobs.
|
||||
|
||||
### Keeping CI Workflows in Sync
|
||||
|
||||
Two workflow files define the same set of parallel test jobs:
|
||||
|
||||
- **`python-merge-tests.yml`** — runs on PRs, merge queue, schedule, and manual dispatch. Uses path-based change detection to skip unaffected integration jobs.
|
||||
- **`python-integration-tests.yml`** — called from the manual integration test orchestrator (`integration-tests-manual.yml`). Always runs all jobs (no path filtering).
|
||||
|
||||
These workflows must be kept in sync. When you add, remove, or modify a test job, update **both** files. The job structure, pytest commands, and xdist flags should match between them. The only difference is that `python-merge-tests.yml` has path filters and conditional job execution, while `python-integration-tests.yml` does not.
|
||||
|
||||
### Updating the CI When Adding Integration Tests for a New Provider
|
||||
|
||||
When adding integration tests for a new provider package, you must update **both** `python-merge-tests.yml` and `python-integration-tests.yml`:
|
||||
|
||||
1. **Add a path filter** for the new provider in the `paths-filter` job in `python-merge-tests.yml` so the CI knows which file changes should trigger those tests.
|
||||
2. **Add the test job to both workflow files** — either add them to the existing `python-tests-misc-integration` job, or create a dedicated job if the provider:
|
||||
- Has a large number of integration tests
|
||||
- Requires special infrastructure setup (emulators, Docker containers, etc.)
|
||||
- Has long-running tests that would slow down the misc job
|
||||
|
||||
The `python-tests-misc-integration` job is intended for small integration test suites that don't need dedicated infrastructure. When a provider's integration tests grow large or gain special requirements, split them out into their own job (like `python-tests-functions` was split out for Azure Functions + Durable Task).
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
+27
-1
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0rc2] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- **agent-framework-core**: Support Agent Skills ([#4210](https://github.com/microsoft/agent-framework/pull/4210))
|
||||
- **agent-framework-core**: Add embedding abstractions and OpenAI implementation (Phase 1) ([#4153](https://github.com/microsoft/agent-framework/pull/4153))
|
||||
- **agent-framework-core**: Add Foundry Memory Context Provider ([#3943](https://github.com/microsoft/agent-framework/pull/3943))
|
||||
- **agent-framework-core**: Add `max_function_calls` to `FunctionInvocationConfiguration` ([#4175](https://github.com/microsoft/agent-framework/pull/4175))
|
||||
- **agent-framework-core**: Add `CreateConversationExecutor`, fix input routing, remove unused handler layer ([#4159](https://github.com/microsoft/agent-framework/pull/4159))
|
||||
- **agent-framework-azure-ai-search**: Azure AI Search provider improvements - EmbeddingGenerator, async context manager, KB message handling ([#4212](https://github.com/microsoft/agent-framework/pull/4212))
|
||||
- **agent-framework-azure-ai-search**: Enhance Azure AI Search Citations with Document URLs in Foundry V2 ([#4028](https://github.com/microsoft/agent-framework/pull/4028))
|
||||
- **agent-framework-ag-ui**: Add Workflow Support, Harden Streaming Semantics, and add Dynamic Handoff Demo ([#3911](https://github.com/microsoft/agent-framework/pull/3911))
|
||||
|
||||
### Changed
|
||||
|
||||
- **agent-framework-declarative**: [BREAKING] Add `InvokeFunctionTool` action for declarative workflows ([#3716](https://github.com/microsoft/agent-framework/pull/3716))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-core**: Fix thread corruption when `max_iterations` is reached ([#4234](https://github.com/microsoft/agent-framework/pull/4234))
|
||||
- **agent-framework-core**: Fix workflow runner concurrent processing ([#4143](https://github.com/microsoft/agent-framework/pull/4143))
|
||||
- **agent-framework-core**: Fix doubled `tool_call` arguments in `MESSAGES_SNAPSHOT` when streaming ([#4200](https://github.com/microsoft/agent-framework/pull/4200))
|
||||
- **agent-framework-core**: Fix OpenAI chat client compatibility with third-party endpoints and OTel 0.4.14 ([#4161](https://github.com/microsoft/agent-framework/pull/4161))
|
||||
- **agent-framework-claude**: Fix `structured_output` propagation in `ClaudeAgent` ([#4137](https://github.com/microsoft/agent-framework/pull/4137))
|
||||
|
||||
## [1.0.0rc1] - 2026-02-19
|
||||
|
||||
Release candidate for **agent-framework-core** and **agent-framework-azure-ai** packages.
|
||||
@@ -675,7 +700,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc1...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc2...HEAD
|
||||
[1.0.0rc2]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc1...python-1.0.0rc2
|
||||
[1.0.0rc1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260212...python-1.0.0rc1
|
||||
[1.0.0b260212]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260210...python-1.0.0b260212
|
||||
[1.0.0b260210]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260130...python-1.0.0b260210
|
||||
|
||||
@@ -664,3 +664,31 @@ packages/core/
|
||||
- Factory functions with parameters should be regular functions, not fixtures (fixtures can't accept arguments)
|
||||
- Import factory functions explicitly: `from conftest import create_test_request`
|
||||
- Fixtures should use simple names that describe what they provide: `mapper`, `test_request`, `mock_client`
|
||||
|
||||
### Integration Test Markers
|
||||
|
||||
New integration tests that call external services must have all three markers:
|
||||
|
||||
```python
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_chat_completion() -> None:
|
||||
...
|
||||
```
|
||||
|
||||
- `@pytest.mark.flaky` — marks the test as potentially flaky since it depends on external services
|
||||
- `@pytest.mark.integration` — enables selecting/excluding integration tests with `-m integration` / `-m "not integration"`
|
||||
- `@skip_if_..._integration_tests_disabled` — skips the test when required API keys or service endpoints are missing
|
||||
|
||||
For test modules where all tests are integration tests, use `pytestmark`:
|
||||
|
||||
```python
|
||||
pytestmark = [
|
||||
pytest.mark.flaky,
|
||||
pytest.mark.integration,
|
||||
pytest.mark.sample("01_single_agent"),
|
||||
]
|
||||
```
|
||||
|
||||
When adding integration tests for a new provider, update the path filters and job assignments in **both** `python-merge-tests.yml` and `python-integration-tests.yml` — these workflows must be kept in sync. See the `python-testing` skill for details.
|
||||
|
||||
+12
-2
@@ -121,9 +121,17 @@ client = OpenAIChatClient(env_file_path="openai.env")
|
||||
|
||||
## Tests
|
||||
|
||||
All the tests are located in the `tests` folder of each package. There are tests that are marked with a `@skip_if_..._integration_tests_disabled` decorator, these are integration tests that require an external service to be running, like OpenAI or Azure OpenAI.
|
||||
All the tests are located in the `tests` folder of each package. Tests marked with `@pytest.mark.integration` and `@skip_if_..._integration_tests_disabled` are integration tests that require external services (e.g., OpenAI, Azure OpenAI). They are automatically skipped when the required API keys or service endpoints are not configured in your environment or `.env` file.
|
||||
|
||||
If you want to run these tests, you need to set the environment variable `RUN_INTEGRATION_TESTS` to `true` and have the appropriate key per services set in your environment or in a `.env` file.
|
||||
You can select or exclude integration tests using pytest markers:
|
||||
|
||||
```bash
|
||||
# Run only unit tests (exclude integration tests)
|
||||
uv run poe all-tests -m "not integration"
|
||||
|
||||
# Run only integration tests
|
||||
uv run poe all-tests -m integration
|
||||
```
|
||||
|
||||
Alternatively, you can run them using VSCode Tasks. Open the command palette
|
||||
(`Ctrl+Shift+P`) and type `Tasks: Run Task`. Select `Test` from the list.
|
||||
@@ -134,6 +142,8 @@ If you want to run the tests for a single package, you can use the `uv run poe t
|
||||
uv run poe --directory packages/core test
|
||||
```
|
||||
|
||||
Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The `all-tests` task also uses xdist across all packages.
|
||||
|
||||
These commands also output the coverage report.
|
||||
|
||||
## Code quality checks
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260219"
|
||||
version = "1.0.0b260225"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc1",
|
||||
"agent-framework-core>=1.0.0rc2",
|
||||
"a2a-sdk>=0.3.5",
|
||||
]
|
||||
|
||||
@@ -37,6 +37,7 @@ environments = [
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
@@ -46,6 +47,9 @@ filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
@@ -79,6 +83,7 @@ exclude_dirs = ["tests"]
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
|
||||
test = "pytest --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
@@ -5,17 +5,27 @@ AG-UI protocol integration for building agent UIs with the AG-UI standard.
|
||||
## Main Classes
|
||||
|
||||
- **`AgentFrameworkAgent`** - Wraps agents for AG-UI compatibility
|
||||
- **`AgentFrameworkWorkflow`** - Wraps native `Workflow` objects, or accepts `workflow_factory(thread_id)` for thread-scoped workflow instances without subclassing
|
||||
- **`AGUIChatClient`** - Chat client that speaks AG-UI protocol
|
||||
- **`AGUIHttpService`** - HTTP service for AG-UI endpoints
|
||||
- **`AGUIEventConverter`** - Converts between Agent Framework and AG-UI events
|
||||
- **`add_agent_framework_fastapi_endpoint()`** - Add AG-UI endpoint to FastAPI app
|
||||
- **`add_agent_framework_fastapi_endpoint()`** - Add AG-UI endpoint to FastAPI app (`SupportsAgentRun` or `Workflow`)
|
||||
|
||||
## Types
|
||||
|
||||
- **`AGUIRequest`** / **`AGUIChatOptions`** - Request types
|
||||
- **`availableInterrupts` / `resume`** - Optional interrupt configuration and continuation payloads
|
||||
- **`AgentState`** / **`RunMetadata`** - State management types
|
||||
- **`PredictStateConfig`** - Configuration for state prediction
|
||||
|
||||
## Protocol Notes
|
||||
|
||||
- Outbound custom events are emitted as AG-UI `CUSTOM`.
|
||||
- Usage metadata from `Content(type="usage")` is surfaced as `CUSTOM` events with `name="usage"`.
|
||||
- Inbound custom event aliases are accepted: `CUSTOM`, `CUSTOM_EVENT`, and `custom_event`.
|
||||
- Multimodal user inputs support both legacy (`text`, `binary`) and draft-style (`image`, `audio`, `video`, `document`) shapes.
|
||||
- `RUN_FINISHED.interrupt` can be emitted for pause/request-info flows, and interruption metadata is preserved in converters.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
|
||||
@@ -36,6 +36,44 @@ add_agent_framework_fastapi_endpoint(app, agent, "/")
|
||||
# Run with: uvicorn main:app --reload
|
||||
```
|
||||
|
||||
### Server (Host a Workflow)
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from agent_framework import WorkflowBuilder, WorkflowContext, executor
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
|
||||
@executor(id="start")
|
||||
async def start(message: str, ctx: WorkflowContext) -> None:
|
||||
await ctx.yield_output(f"Workflow received: {message}")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).build()
|
||||
|
||||
app = FastAPI()
|
||||
add_agent_framework_fastapi_endpoint(app, workflow, "/")
|
||||
```
|
||||
|
||||
### Server (Thread-Scoped WorkflowBuilder)
|
||||
|
||||
Use `workflow_factory` when your workflow keeps runtime state (for example pending `request_info` interrupts) and must be isolated per AG-UI thread:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from agent_framework import Workflow, WorkflowBuilder
|
||||
from agent_framework.ag_ui import AgentFrameworkWorkflow, add_agent_framework_fastapi_endpoint
|
||||
|
||||
def build_workflow_for_thread(thread_id: str) -> Workflow:
|
||||
# Build a fresh workflow instance for each thread id.
|
||||
return WorkflowBuilder(start_executor=...).build()
|
||||
|
||||
app = FastAPI()
|
||||
thread_scoped_workflow = AgentFrameworkWorkflow(
|
||||
workflow_factory=build_workflow_for_thread,
|
||||
name="my_workflow",
|
||||
)
|
||||
add_agent_framework_fastapi_endpoint(app, thread_scoped_workflow, "/")
|
||||
```
|
||||
|
||||
### Client (Connect to an AG-UI Server)
|
||||
|
||||
```python
|
||||
@@ -59,6 +97,7 @@ The `AGUIChatClient` supports:
|
||||
- Hybrid tool execution (client-side + server-side tools)
|
||||
- Automatic thread management for conversation continuity
|
||||
- Integration with `Agent` for client-side history management
|
||||
- Interrupt metadata passthrough (`availableInterrupts` and `resume`)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -81,6 +120,13 @@ This integration supports all 7 AG-UI features:
|
||||
6. **Shared State**: Bidirectional state sync between client and server
|
||||
7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution
|
||||
|
||||
Additional compatibility and draft support:
|
||||
- Native `Workflow` endpoint registration via `add_agent_framework_fastapi_endpoint(...)`
|
||||
- Workflow-to-AG-UI event mapping (run/step/activity/tool/custom events)
|
||||
- Custom event compatibility for inbound `CUSTOM`, `CUSTOM_EVENT`, and `custom_event`
|
||||
- Pragmatic multimodal input parsing for both legacy (`binary`) and draft media-part shapes
|
||||
- Pragmatic interrupt/resume handling (`availableInterrupts`, `resume`, and `RUN_FINISHED.interrupt`)
|
||||
|
||||
## Security: Authentication & Authorization
|
||||
|
||||
The AG-UI endpoint does not enforce authentication by default. **For production deployments, you should add authentication** using FastAPI's dependency injection system via the `dependencies` parameter.
|
||||
|
||||
@@ -10,6 +10,7 @@ from ._endpoint import add_agent_framework_fastapi_endpoint
|
||||
from ._event_converters import AGUIEventConverter
|
||||
from ._http_service import AGUIHttpService
|
||||
from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata
|
||||
from ._workflow import AgentFrameworkWorkflow, WorkflowFactory
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -21,6 +22,8 @@ DEFAULT_TAGS = ["AG-UI"]
|
||||
|
||||
__all__ = [
|
||||
"AgentFrameworkAgent",
|
||||
"AgentFrameworkWorkflow",
|
||||
"WorkflowFactory",
|
||||
"add_agent_framework_fastapi_endpoint",
|
||||
"AGUIChatClient",
|
||||
"AGUIChatOptions",
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
||||
from ag_ui.core import BaseEvent
|
||||
from agent_framework import SupportsAgentRun
|
||||
|
||||
from ._run import run_agent_stream
|
||||
from ._agent_run import run_agent_stream
|
||||
|
||||
|
||||
class AgentConfig:
|
||||
@@ -101,11 +101,11 @@ class AgentFrameworkAgent:
|
||||
require_confirmation=require_confirmation,
|
||||
)
|
||||
|
||||
async def run_agent(
|
||||
async def run(
|
||||
self,
|
||||
input_data: dict[str, Any],
|
||||
) -> AsyncGenerator[BaseEvent, None]:
|
||||
"""Run the agent and yield AG-UI events.
|
||||
"""Run the wrapped agent and yield AG-UI events.
|
||||
|
||||
Args:
|
||||
input_data: The AG-UI run input containing messages, state, etc.
|
||||
|
||||
+51
-283
@@ -2,20 +2,18 @@
|
||||
|
||||
"""Simplified AG-UI orchestration - single linear flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
from __future__ import annotations # noqa: I001
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
MessagesSnapshotEvent,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
@@ -23,7 +21,6 @@ from ag_ui.core import (
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import (
|
||||
@@ -45,6 +42,14 @@ from agent_framework.exceptions import AgentInvalidResponseException
|
||||
from ._message_adapters import normalize_agui_input_messages
|
||||
from ._orchestration._predictive_state import PredictiveStateHandler
|
||||
from ._orchestration._tooling import collect_server_tools, merge_tools, register_additional_client_tools
|
||||
from ._run_common import (
|
||||
FlowState,
|
||||
_build_run_finished_event, # type: ignore
|
||||
_emit_content, # type: ignore
|
||||
_extract_resume_payload, # type: ignore
|
||||
_has_only_tool_calls, # type: ignore
|
||||
_normalize_resume_interrupts, # type: ignore
|
||||
)
|
||||
from ._utils import (
|
||||
convert_agui_tools_to_agent_framework,
|
||||
generate_event_id,
|
||||
@@ -86,20 +91,6 @@ def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, An
|
||||
return safe_metadata
|
||||
|
||||
|
||||
def _has_only_tool_calls(contents: list[Any]) -> bool:
|
||||
"""Check if contents have only tool calls (no text).
|
||||
|
||||
Args:
|
||||
contents: List of content items
|
||||
|
||||
Returns:
|
||||
True if there are tool calls but no text content
|
||||
"""
|
||||
has_tool_call = any(getattr(c, "type", None) == "function_call" for c in contents)
|
||||
has_text = any(getattr(c, "type", None) == "text" and getattr(c, "text", None) for c in contents)
|
||||
return has_tool_call and not has_text
|
||||
|
||||
|
||||
def _should_suppress_intermediate_snapshot(
|
||||
tool_name: str | None,
|
||||
predict_state_config: dict[str, dict[str, str]] | None,
|
||||
@@ -164,31 +155,24 @@ def _extract_approved_state_updates(
|
||||
return updates
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowState:
|
||||
"""Minimal explicit state for a single AG-UI run."""
|
||||
|
||||
message_id: str | None = None # Current text message being streamed
|
||||
tool_call_id: str | None = None # Current tool call being streamed
|
||||
tool_call_name: str | None = None # Name of current tool call
|
||||
waiting_for_approval: bool = False # Stop after approval request
|
||||
current_state: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
|
||||
accumulated_text: str = "" # For MessagesSnapshotEvent
|
||||
pending_tool_calls: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_calls_by_id: dict[str, dict[str, Any]] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType]
|
||||
|
||||
def get_tool_name(self, call_id: str | None) -> str | None:
|
||||
"""Get tool name by call ID."""
|
||||
if not call_id or call_id not in self.tool_calls_by_id:
|
||||
return None
|
||||
name = self.tool_calls_by_id[call_id]["function"].get("name")
|
||||
return str(name) if name else None
|
||||
|
||||
def get_pending_without_end(self) -> list[dict[str, Any]]:
|
||||
"""Get tool calls that started but never received an end event (declaration-only)."""
|
||||
return [tc for tc in self.pending_tool_calls if tc.get("id") not in self.tool_calls_ended]
|
||||
def _resume_to_tool_messages(resume_payload: Any) -> list[dict[str, Any]]:
|
||||
"""Convert a resume payload into AG-UI tool messages for approval continuation."""
|
||||
result: list[dict[str, Any]] = []
|
||||
for interrupt in _normalize_resume_interrupts(resume_payload):
|
||||
value = interrupt.get("value")
|
||||
content: str
|
||||
if isinstance(value, str):
|
||||
content = value
|
||||
else:
|
||||
content = json.dumps(make_json_safe(value))
|
||||
result.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": interrupt["id"],
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _normalize_response_stream(response_stream: Any) -> AsyncIterable[Any]:
|
||||
@@ -303,242 +287,6 @@ def _inject_state_context(
|
||||
return result
|
||||
|
||||
|
||||
def _emit_text(content: Content, flow: FlowState, skip_text: bool = False) -> list[BaseEvent]:
|
||||
"""Emit TextMessage events for TextContent."""
|
||||
if not content.text:
|
||||
return []
|
||||
|
||||
# Skip if we're in structured output mode or waiting for approval
|
||||
if skip_text or flow.waiting_for_approval:
|
||||
return []
|
||||
|
||||
events: list[BaseEvent] = []
|
||||
if not flow.message_id:
|
||||
flow.message_id = generate_event_id()
|
||||
events.append(TextMessageStartEvent(message_id=flow.message_id, role="assistant"))
|
||||
|
||||
events.append(TextMessageContentEvent(message_id=flow.message_id, delta=content.text))
|
||||
flow.accumulated_text += content.text
|
||||
return events
|
||||
|
||||
|
||||
def _emit_tool_call(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit ToolCall events for FunctionCallContent."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
tool_call_id = content.call_id or flow.tool_call_id or generate_event_id()
|
||||
|
||||
# Emit start event when we have a new tool call
|
||||
if content.name and tool_call_id != flow.tool_call_id:
|
||||
flow.tool_call_id = tool_call_id
|
||||
flow.tool_call_name = content.name
|
||||
if predictive_handler:
|
||||
predictive_handler.reset_streaming()
|
||||
|
||||
events.append(
|
||||
ToolCallStartEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_name=content.name,
|
||||
parent_message_id=flow.message_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Track for MessagesSnapshotEvent
|
||||
tool_entry = {
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {"name": content.name, "arguments": ""},
|
||||
}
|
||||
flow.pending_tool_calls.append(tool_entry)
|
||||
flow.tool_calls_by_id[tool_call_id] = tool_entry
|
||||
|
||||
elif tool_call_id:
|
||||
flow.tool_call_id = tool_call_id
|
||||
|
||||
# Emit args if present
|
||||
if content.arguments:
|
||||
delta = (
|
||||
content.arguments if isinstance(content.arguments, str) else json.dumps(make_json_safe(content.arguments))
|
||||
)
|
||||
events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=delta))
|
||||
|
||||
# Track args for MessagesSnapshotEvent
|
||||
if tool_call_id in flow.tool_calls_by_id:
|
||||
flow.tool_calls_by_id[tool_call_id]["function"]["arguments"] += delta
|
||||
|
||||
# Emit predictive state deltas
|
||||
if predictive_handler and flow.tool_call_name:
|
||||
delta_events = predictive_handler.emit_streaming_deltas(flow.tool_call_name, delta)
|
||||
events.extend(delta_events)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _emit_tool_result(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit ToolCallResult events for function_result content."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
# Cannot emit tool result without a call_id to associate it with
|
||||
if not content.call_id:
|
||||
return events
|
||||
|
||||
events.append(ToolCallEndEvent(tool_call_id=content.call_id))
|
||||
flow.tool_calls_ended.add(content.call_id) # Track ended tool calls
|
||||
|
||||
result_content = content.result if content.result is not None else ""
|
||||
message_id = generate_event_id()
|
||||
events.append(
|
||||
ToolCallResultEvent(
|
||||
message_id=message_id,
|
||||
tool_call_id=content.call_id,
|
||||
content=result_content,
|
||||
role="tool",
|
||||
)
|
||||
)
|
||||
|
||||
# Track for MessagesSnapshotEvent
|
||||
flow.tool_results.append(
|
||||
{
|
||||
"id": message_id,
|
||||
"role": "tool",
|
||||
"toolCallId": content.call_id,
|
||||
"content": result_content,
|
||||
}
|
||||
)
|
||||
|
||||
# Apply predictive state updates and emit snapshot
|
||||
if predictive_handler:
|
||||
predictive_handler.apply_pending_updates()
|
||||
if flow.current_state:
|
||||
events.append(StateSnapshotEvent(snapshot=flow.current_state))
|
||||
|
||||
# Reset tool tracking and message context
|
||||
# After tool result, any subsequent text should start a new message
|
||||
flow.tool_call_id = None
|
||||
flow.tool_call_name = None
|
||||
|
||||
# Close any open text message before resetting message_id (issue #3568)
|
||||
# This handles the case where a TextMessageStartEvent was emitted for tool-only
|
||||
# messages (Feature #4) but needs to be closed before starting a new message
|
||||
if flow.message_id:
|
||||
logger.debug("Closing text message (issue #3568 fix): message_id=%s", flow.message_id)
|
||||
events.append(TextMessageEndEvent(message_id=flow.message_id))
|
||||
flow.message_id = None # Reset so next text content starts a new message
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _emit_approval_request(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
require_confirmation: bool = True,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit events for function approval request."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
# function_call is required for approval requests - skip if missing
|
||||
func_call = content.function_call
|
||||
if not func_call:
|
||||
logger.warning("Approval request content missing function_call, skipping")
|
||||
return events
|
||||
|
||||
func_name = func_call.name or ""
|
||||
func_call_id = func_call.call_id
|
||||
|
||||
# Extract state from function arguments if predictive
|
||||
if predictive_handler and func_name:
|
||||
parsed_args = func_call.parse_arguments()
|
||||
result = predictive_handler.extract_state_value(func_name, parsed_args)
|
||||
if result:
|
||||
state_key, state_value = result
|
||||
flow.current_state[state_key] = state_value
|
||||
events.append(StateSnapshotEvent(snapshot=flow.current_state))
|
||||
|
||||
# End the original tool call
|
||||
if func_call_id:
|
||||
events.append(ToolCallEndEvent(tool_call_id=func_call_id))
|
||||
flow.tool_calls_ended.add(func_call_id) # Track ended tool calls
|
||||
|
||||
# Emit custom event for UI
|
||||
events.append(
|
||||
CustomEvent(
|
||||
name="function_approval_request",
|
||||
value={
|
||||
"id": content.id,
|
||||
"function_call": {
|
||||
"call_id": func_call_id,
|
||||
"name": func_name,
|
||||
"arguments": make_json_safe(func_call.parse_arguments()),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Emit confirm_changes tool call for UI compatibility
|
||||
# The complete sequence (Start -> Args -> End) signals the UI to show the confirmation dialog
|
||||
if require_confirmation:
|
||||
confirm_id = generate_event_id()
|
||||
events.append(
|
||||
ToolCallStartEvent(
|
||||
tool_call_id=confirm_id,
|
||||
tool_call_name="confirm_changes",
|
||||
parent_message_id=flow.message_id,
|
||||
)
|
||||
)
|
||||
args: dict[str, Any] = {
|
||||
"function_name": func_name,
|
||||
"function_call_id": func_call_id,
|
||||
"function_arguments": make_json_safe(func_call.parse_arguments()) or {},
|
||||
"steps": [{"description": f"Execute {func_name}", "status": "enabled"}],
|
||||
}
|
||||
args_json = json.dumps(args)
|
||||
events.append(ToolCallArgsEvent(tool_call_id=confirm_id, delta=args_json))
|
||||
events.append(ToolCallEndEvent(tool_call_id=confirm_id))
|
||||
|
||||
# Track confirm_changes in pending_tool_calls for MessagesSnapshotEvent
|
||||
# The frontend needs to see this in the snapshot to render the confirmation dialog
|
||||
confirm_entry = {
|
||||
"id": confirm_id,
|
||||
"type": "function",
|
||||
"function": {"name": "confirm_changes", "arguments": args_json},
|
||||
}
|
||||
flow.pending_tool_calls.append(confirm_entry)
|
||||
flow.tool_calls_by_id[confirm_id] = confirm_entry
|
||||
flow.tool_calls_ended.add(confirm_id) # Mark as ended since we emit End event
|
||||
|
||||
flow.waiting_for_approval = True
|
||||
return events
|
||||
|
||||
|
||||
def _emit_content(
|
||||
content: Any,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
skip_text: bool = False,
|
||||
require_confirmation: bool = True,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit appropriate events for any content type."""
|
||||
content_type = getattr(content, "type", None)
|
||||
if content_type == "text":
|
||||
return _emit_text(content, flow, skip_text)
|
||||
elif content_type == "function_call":
|
||||
return _emit_tool_call(content, flow, predictive_handler)
|
||||
elif content_type == "function_result":
|
||||
return _emit_tool_result(content, flow, predictive_handler)
|
||||
elif content_type == "function_approval_request":
|
||||
return _emit_approval_request(content, flow, predictive_handler, require_confirmation)
|
||||
return []
|
||||
|
||||
|
||||
def _is_confirm_changes_response(messages: list[Any]) -> bool:
|
||||
"""Check if the last message is a confirm_changes tool result (state confirmation flow).
|
||||
|
||||
@@ -831,7 +579,14 @@ async def run_agent_stream(
|
||||
)
|
||||
|
||||
# Normalize messages
|
||||
raw_messages = input_data.get("messages", [])
|
||||
available_interrupts = input_data.get("available_interrupts") or input_data.get("availableInterrupts")
|
||||
raw_messages = list(cast(list[dict[str, Any]], input_data.get("messages", []) or []))
|
||||
resume_messages = _resume_to_tool_messages(_extract_resume_payload(input_data))
|
||||
if available_interrupts:
|
||||
logger.debug("Received available interrupts metadata: %s", available_interrupts)
|
||||
if resume_messages:
|
||||
logger.info(f"Appending {len(resume_messages)} synthesized resume message(s) to AG-UI input.")
|
||||
raw_messages.extend(resume_messages)
|
||||
messages, snapshot_messages = normalize_agui_input_messages(raw_messages)
|
||||
|
||||
# Check for structured output mode (skip text content)
|
||||
@@ -847,7 +602,7 @@ async def run_agent_stream(
|
||||
if not messages:
|
||||
logger.warning("No messages provided in AG-UI input")
|
||||
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
|
||||
yield RunFinishedEvent(run_id=run_id, thread_id=thread_id)
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id)
|
||||
return
|
||||
|
||||
# Prepare tools
|
||||
@@ -906,7 +661,7 @@ async def run_agent_stream(
|
||||
yield StateSnapshotEvent(snapshot=flow.current_state)
|
||||
for event in _handle_step_based_approval(messages):
|
||||
yield event
|
||||
yield RunFinishedEvent(run_id=run_id, thread_id=thread_id)
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id)
|
||||
return
|
||||
|
||||
# Inject state context message so the model knows current application state
|
||||
@@ -1099,6 +854,19 @@ async def run_agent_stream(
|
||||
flow.tool_calls_by_id[confirm_id] = confirm_entry
|
||||
flow.tool_calls_ended.add(confirm_id) # Mark as ended since we emit End event
|
||||
flow.waiting_for_approval = True
|
||||
flow.interrupts = [
|
||||
{
|
||||
"id": str(confirm_id),
|
||||
"value": {
|
||||
"type": "function_approval_request",
|
||||
"function_call": {
|
||||
"call_id": tool_call_id,
|
||||
"name": tool_name,
|
||||
"arguments": function_arguments,
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Close any open message
|
||||
if flow.message_id:
|
||||
@@ -1122,4 +890,4 @@ async def run_agent_stream(
|
||||
|
||||
# Always emit RunFinished - confirm_changes tool call is complete (Start -> Args -> End)
|
||||
# The UI will show confirmation dialog and send a new request when user responds
|
||||
yield RunFinishedEvent(run_id=run_id, thread_id=thread_id)
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id, interrupts=flow.interrupts)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user