Compare commits

..
398 changed files with 5413 additions and 15832 deletions
-1
View File
@@ -20,7 +20,6 @@ ignorePatterns:
- pattern: "https://your-resource.openai.azure.com/"
- pattern: "http://host.docker.internal"
- pattern: "https://openai.github.io/openai-agents-js/openai/agents/classes/"
- pattern: "https:\/\/dotnet.microsoft.com\/download"
# excludedDirs:
# Folders which include links to localhost, since it's not ignored with regular expressions
baseUrl: https://github.com/microsoft/agent-framework/
+46 -107
View File
@@ -59,20 +59,20 @@ jobs:
if: steps.filter.outputs.dotnet != 'true'
run: echo "NOT dotnet file"
# Build the full solution (including samples) on all TFMs. No tests.
dotnet-build:
dotnet-build-and-test:
needs: paths-filter
if: needs.paths-filter.outputs.dotnetChanges == 'true'
strategy:
fail-fast: false
matrix:
include:
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release }
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release, integration-tests: true, environment: "integration" }
- { targetFramework: "net9.0", os: "windows-latest", configuration: Debug }
- { targetFramework: "net8.0", os: "ubuntu-latest", configuration: Release }
- { targetFramework: "net472", os: "windows-latest", configuration: Release }
- { targetFramework: "net472", os: "windows-latest", configuration: Release, integration-tests: true, environment: "integration" }
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@v6
with:
@@ -84,6 +84,16 @@ jobs:
python
workflow-samples
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
- name: Start Azure Cosmos DB Emulator
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
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 "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
with:
@@ -130,98 +140,25 @@ jobs:
popd
rm -rf "$TEMP_DIR"
# Build src+tests only (no samples) for a single TFM and run tests.
dotnet-test:
needs: paths-filter
if: needs.paths-filter.outputs.dotnetChanges == 'true'
strategy:
fail-fast: false
matrix:
include:
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release, integration-tests: true, environment: "integration" }
- { targetFramework: "net472", os: "windows-latest", configuration: Release, integration-tests: true, environment: "integration" }
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
.
.github
dotnet
python
workflow-samples
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
- name: Start Azure Cosmos DB Emulator
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
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 "COSMOSDB_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: Generate test solution (no samples)
shell: pwsh
run: |
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
-Solution dotnet/agent-framework-dotnet.slnx `
-TargetFramework ${{ matrix.targetFramework }} `
-Configuration ${{ matrix.configuration }} `
-ExcludeSamples `
-OutputPath dotnet/filtered.slnx `
-Verbose
- name: Build src and tests
shell: bash
run: dotnet build dotnet/filtered.slnx -c ${{ matrix.configuration }} -f ${{ matrix.targetFramework }} --warnaserror
- name: Generate test-type filtered solutions
shell: pwsh
run: |
$commonArgs = @{
Solution = "dotnet/filtered.slnx"
TargetFramework = "${{ matrix.targetFramework }}"
Configuration = "${{ matrix.configuration }}"
Verbose = $true
}
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameFilter "*UnitTests*" `
-OutputPath dotnet/filtered-unit.slnx
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameFilter "*IntegrationTests*" `
-OutputPath dotnet/filtered-integration.slnx
- name: Run Unit Tests
shell: pwsh
working-directory: dotnet
shell: bash
run: |
$coverageSettings = Join-Path $PWD "tests/coverage.runsettings"
$coverageArgs = @()
if ("${{ matrix.targetFramework }}" -eq "${{ env.COVERAGE_FRAMEWORK }}") {
$coverageArgs = @(
"--coverage",
"--coverage-output-format", "cobertura",
"--coverage-settings", $coverageSettings,
"--results-directory", "../TestResults/Coverage/"
)
}
export UT_PROJECTS=$(find ./dotnet -type f -name "*.UnitTests.csproj" | tr '\n' ' ')
for project in $UT_PROJECTS; do
# Query the project's target frameworks using MSBuild with the current configuration
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')
dotnet test --solution ./filtered-unit.slnx `
-f ${{ matrix.targetFramework }} `
-c ${{ matrix.configuration }} `
--no-build -v Normal `
--report-xunit-trx `
--ignore-exit-code 8 `
@coverageArgs
# Check if the project supports the target framework
if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then
if [[ "${{ matrix.targetFramework }}" == "${{ env.COVERAGE_FRAMEWORK }}" ]]; then
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --collect:"XPlat Code Coverage" --results-directory:"TestResults/Coverage/" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute=GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
else
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx
fi
else
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
fi
done
env:
# Cosmos DB Emulator connection settings
COSMOSDB_ENDPOINT: https://localhost:8081
@@ -248,19 +185,21 @@ jobs:
id: azure-functions-setup
- name: Run Integration Tests
shell: pwsh
working-directory: dotnet
shell: bash
if: github.event_name != 'pull_request' && matrix.integration-tests
run: |
dotnet test --solution ./filtered-integration.slnx `
-f ${{ matrix.targetFramework }} `
-c ${{ matrix.configuration }} `
--no-build -v Normal `
--report-xunit-trx `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--parallel-algorithm aggressive `
--max-threads 2.0x
export INTEGRATION_TEST_PROJECTS=$(find ./dotnet -type f -name "*IntegrationTests.csproj" | tr '\n' ' ')
for project in $INTEGRATION_TEST_PROJECTS; do
# Query the project's target frameworks using MSBuild with the current configuration
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')
# Check if the project supports the target framework
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:
# Cosmos DB Emulator connection settings
COSMOSDB_ENDPOINT: https://localhost:8081
@@ -283,7 +222,7 @@ jobs:
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.1
with:
reports: "./TestResults/Coverage/**/*.cobertura.xml"
reports: "./TestResults/Coverage/**/coverage.cobertura.xml"
targetdir: "./TestResults/Reports"
reporttypes: "HtmlInline;JsonSummary"
@@ -297,13 +236,13 @@ jobs:
- name: Check coverage
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
shell: pwsh
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
run: .github/workflows/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
dotnet-build-and-test-check:
if: always()
runs-on: ubuntu-latest
needs: [dotnet-build, dotnet-test]
needs: [dotnet-build-and-test]
steps:
- name: Get Date
shell: bash
+1 -4
View File
@@ -29,7 +29,4 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
timeout: 3600
interval: 30
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
# created by an org-level GitHub App (MSDO), not by any workflow in this repo.
# They are outside our control and their transient failures should not block merges.
ignored: CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results
ignored: CodeQL,CodeQL analysis (csharp)
File diff suppressed because it is too large Load Diff
+5 -50
View File
@@ -17,17 +17,14 @@ dotnet format # Auto-fix formatting for all projects
# Build/test/format a specific project (preferred for isolated/internal changes)
dotnet build src/Microsoft.Agents.AI.<Package> --tl:off
dotnet test --project tests/Microsoft.Agents.AI.<Package>.UnitTests
dotnet test tests/Microsoft.Agents.AI.<Package>.UnitTests
dotnet format src/Microsoft.Agents.AI.<Package>
# Run a single test
# Replace the filter values with the appropriate assembly, namespace, class, and method names for the test you want to run and use * as a wildcard elsewhere, e.g. "/*/*/HttpClientTests/GetAsync_ReturnsSuccessStatusCode"
# Use `--ignore-exit-code 8` to avoid failing the build when no tests are found for some projects
dotnet test --filter-query "/<assemblyFilter>/<namespaceFilter>/<classFilter>/<methodFilter>" --ignore-exit-code 8
dotnet test --filter "FullyQualifiedName~Namespace.TestClassName.TestMethodName"
# Run unit tests only
# Use `--ignore-exit-code 8` to avoid failing the build when no tests are found for integration test projects
dotnet test --filter-query "/*UnitTests*/*/*/*" --ignore-exit-code 8
dotnet test --filter FullyQualifiedName\~UnitTests
```
Use `--tl:off` when building to avoid flickering when running commands in the agent.
@@ -59,7 +56,7 @@ Example: Running tests for a single project using .NET 10.
```bash
# From dotnet/ directory
dotnet test --project ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0
dotnet test ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0
```
Example: Running a single test in a specific project using .NET 10.
@@ -67,7 +64,7 @@ Provide the full namespace, class name, and method name for the test you want to
```bash
# From dotnet/ directory
dotnet test --project ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0 --filter-query "/*/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests/CloningConstructorCopiesProperties"
dotnet test ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0 --filter "FullyQualifiedName~Microsoft.Agents.AI.Abstractions.UnitTests.AgentRunOptionsTests.CloningConstructorCopiesProperties"
```
### Multi-target framework tip
@@ -86,45 +83,3 @@ Just remember to run `dotnet restore` after pulling changes, making changes to p
Unit tests target both .NET Framework as well as .NET Core. When running on Linux, only the .NET Core tests can be run, as .NET Framework is not supported on Linux.
To run only the .NET Core tests, use the `-f net10.0` option with `dotnet test`.
### Microsoft Testing Platform (MTP)
Tests use the [Microsoft Testing Platform](https://learn.microsoft.com/dotnet/core/testing/unit-testing-platform-intro) via xUnit v3. Key differences from the legacy VSTest runner:
- **`dotnet test` requires `--project`** to specify a test project directly (positional arguments are no longer supported).
- **Test output** uses the MTP format (e.g., `[✓112/x0/↓0]` progress and `Test run summary: Passed!`).
- **TRX reports** use `--report-xunit-trx` instead of `--logger trx`.
- **Code coverage** uses `Microsoft.Testing.Extensions.CodeCoverage` with `--coverage --coverage-output-format cobertura`.
- **Running a test project directly** is supported via `dotnet run --project <test-project>`. This bypasses the `dotnet test` infrastructure and runs the test executable directly with the MTP command line.
- **Running tests across the solution** with a filter may cause some projects to match zero tests, which MTP treats as a failure (exit code 8). Use `--ignore-exit-code 8` to suppress this:
```bash
# Run all unit tests across the solution, ignoring projects with no matching tests
dotnet test --solution ./agent-framework-dotnet.slnx --no-build -f net10.0 --ignore-exit-code 8
```
- **Running tests with `--solution` for a specific TFM** requires all projects in the solution to support that TFM. Not all projects target every framework (e.g., some are `net10.0`-only). Use `./dotnet/eng/scripts/New-FilteredSolution.ps1` to generate a filtered solution:
```powershell
# Generate a filtered solution for net472 and run tests
$filtered = ./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472
dotnet test --solution $filtered --no-build -f net472 --ignore-exit-code 8
# Exclude samples and keep only unit test projects
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -ExcludeSamples -TestProjectNameFilter "*UnitTests*" -OutputPath dotnet/filtered-unit.slnx
```
```bash
# Run tests via dotnet test (uses MTP under the hood)
dotnet test --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0
# Run tests with code coverage (Cobertura format)
dotnet test --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0 --coverage --coverage-output-format cobertura --coverage-settings ./tests/coverage.runsettings
# Run tests directly via dotnet run (MTP native command line)
dotnet run --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0
# Show MTP command line help
dotnet run --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0 -- -?
```
+11 -9
View File
@@ -19,8 +19,8 @@
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0-beta.1" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="2.0.0-beta.1" />
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.8" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
@@ -35,7 +35,7 @@
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="System.ClientModel" Version="1.9.0" />
<PackageVersion Include="System.ClientModel" Version="1.8.1" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
@@ -94,7 +94,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Agents.AzureAI" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Plugins.OpenApi" Version="1.67.0" />
<!-- Agent SDKs -->
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.29" />
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.23" />
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
<!-- M365 Agents SDK -->
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
@@ -140,10 +140,12 @@
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' == 'net10.0'" Version="10.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageVersion Include="Moq" Version="[4.18.4]" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xRetry.v3" Version="1.0.0-rc3" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.4.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
<PackageVersion Include="xretry" Version="1.9.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<!-- Symbols -->
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<!-- Toolset -->
@@ -185,4 +187,4 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
</Project>
+3 -13
View File
@@ -284,13 +284,8 @@
</Folder>
<Folder Name="/Samples/05-end-to-end/HostedAgents/">
<Project Path="samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/AspNetAgentAuthorization/">
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml" />
@@ -316,6 +311,7 @@
</Folder>
<Folder Name="/Solution Items/.github/workflows/">
<File Path="../.github/workflows/dotnet-build-and-test.yml" />
<File Path="../.github/workflows/dotnet-check-coverage.ps1" />
<File Path="../.github/workflows/dotnet-format.yml" />
</Folder>
<Folder Name="/Solution Items/demos/">
@@ -352,10 +348,6 @@
<File Path="eng/MSBuild/Shared.props" />
<File Path="eng/MSBuild/Shared.targets" />
</Folder>
<Folder Name="/Solution Items/eng/scripts/">
<File Path="eng/scripts/dotnet-check-coverage.ps1" />
<File Path="eng/scripts/New-FilteredSolution.ps1" />
</Folder>
<Folder Name="/Solution Items/nuget/">
<File Path="nuget/icon.png" />
<File Path="nuget/nuget-package.props" />
@@ -421,10 +413,6 @@
<File Path="src/Shared/IntegrationTests/OpenAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/README.md" />
</Folder>
<Folder Name="/Solution Items/src/Shared/IntegrationTestsAzureCredentials/">
<File Path="src/Shared/IntegrationTestsAzureCredentials/README.md" />
<File Path="src/Shared/IntegrationTestsAzureCredentials/TestAzureCliCredentials.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/Samples/">
<File Path="src/Shared/Samples/BaseSample.cs" />
<File Path="src/Shared/Samples/README.md" />
@@ -476,6 +464,8 @@
<Folder Name="/Tests/" />
<Folder Name="/Tests/IntegrationTests/">
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/AgentConversation.IntegrationTests/AgentConversation.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Abstractions.IntegrationTests/Microsoft.Agents.AI.Abstractions.IntegrationTests.csproj" />
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
<Project Path="tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
-3
View File
@@ -8,9 +8,6 @@
<ItemGroup Condition="'$(InjectSharedIntegrationTestCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\IntegrationTests\*.cs" LinkBase="Shared\IntegrationTests" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedIntegrationTestAzureCredentialsCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\IntegrationTestsAzureCredentials\*.cs" LinkBase="Shared\IntegrationTestsAzureCredentials" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedBuildTestCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\CodeTests\*.cs" LinkBase="Shared\CodeTests" />
</ItemGroup>
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env pwsh
# Copyright (c) Microsoft. All rights reserved.
<#
.SYNOPSIS
Generates a filtered .slnx solution file by removing projects that don't match the specified criteria.
.DESCRIPTION
Parses a .slnx solution file and applies one or more filters:
- Removes projects that don't support the specified target framework (via MSBuild query).
- Optionally removes all sample projects (under samples/).
- Optionally filters test projects by name pattern (e.g., only *UnitTests*).
Writes the filtered solution to the specified output path and prints the path.
.PARAMETER Solution
Path to the source .slnx solution file.
.PARAMETER TargetFramework
The target framework to filter by (e.g., net10.0, net472).
.PARAMETER Configuration
Optional MSBuild configuration used when querying TargetFrameworks. Defaults to Debug.
.PARAMETER TestProjectNameFilter
Optional wildcard pattern to filter test project names (e.g., *UnitTests*, *IntegrationTests*).
When specified, only test projects whose filename matches this pattern are kept.
.PARAMETER ExcludeSamples
When specified, removes all projects under the samples/ directory from the solution.
.PARAMETER OutputPath
Optional output path for the filtered .slnx file. If not specified, a temp file is created.
.EXAMPLE
# Generate a filtered solution and run tests
$filtered = ./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472
dotnet test --solution $filtered --no-build -f net472
.EXAMPLE
# Generate a solution with only unit test projects
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameFilter "*UnitTests*" -OutputPath filtered-unit.slnx
.EXAMPLE
# Inline usage with dotnet test (PowerShell)
dotnet test --solution (./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472) --no-build -f net472
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Solution,
[Parameter(Mandatory)]
[string]$TargetFramework,
[string]$Configuration = "Debug",
[string]$TestProjectNameFilter,
[switch]$ExcludeSamples,
[string]$OutputPath
)
$ErrorActionPreference = "Stop"
# Resolve the solution path
$solutionPath = Resolve-Path $Solution
$solutionDir = Split-Path $solutionPath -Parent
if (-not $OutputPath) {
$OutputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "filtered-$(Split-Path $solutionPath -Leaf)")
}
# Parse the .slnx XML
[xml]$slnx = Get-Content $solutionPath -Raw
$removed = @()
$kept = @()
# Remove sample projects if requested
if ($ExcludeSamples) {
$sampleProjects = $slnx.SelectNodes("//Project[contains(@Path, 'samples/')]")
foreach ($proj in $sampleProjects) {
$projRelPath = $proj.GetAttribute("Path")
Write-Verbose "Removing (sample): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
}
Write-Host "Removed $($sampleProjects.Count) sample project(s)." -ForegroundColor Yellow
}
# Filter all remaining projects by target framework
$allProjects = $slnx.SelectNodes("//Project")
foreach ($proj in $allProjects) {
$projRelPath = $proj.GetAttribute("Path")
$projFullPath = Join-Path $solutionDir $projRelPath
$projFileName = Split-Path $projRelPath -Leaf
$isTestProject = $projRelPath -like "*tests/*"
# Filter test projects by name pattern if specified
if ($isTestProject -and $TestProjectNameFilter -and ($projFileName -notlike $TestProjectNameFilter)) {
Write-Verbose "Removing (name filter): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
continue
}
if (-not (Test-Path $projFullPath)) {
Write-Verbose "Project not found, keeping in solution: $projRelPath"
$kept += $projRelPath
continue
}
# Query the project's target frameworks using MSBuild
$targetFrameworks = & dotnet msbuild $projFullPath -getProperty:TargetFrameworks -p:Configuration=$Configuration -nologo 2>$null
$targetFrameworks = $targetFrameworks.Trim()
if ($targetFrameworks -like "*$TargetFramework*") {
Write-Verbose "Keeping: $projRelPath (targets: $targetFrameworks)"
$kept += $projRelPath
}
else {
Write-Verbose "Removing: $projRelPath (targets: $targetFrameworks, missing: $TargetFramework)"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
}
}
# Write the filtered solution
$slnx.Save($OutputPath)
# Report results to stderr so stdout is clean for piping
Write-Host "Filtered solution written to: $OutputPath" -ForegroundColor Green
if ($removed.Count -gt 0) {
Write-Host "Removed $($removed.Count) project(s):" -ForegroundColor Yellow
foreach ($r in $removed) {
Write-Host " - $r" -ForegroundColor Yellow
}
}
Write-Host "Kept $($kept.Count) project(s)." -ForegroundColor Green
# Output the path for piping
Write-Output $OutputPath
-3
View File
@@ -3,8 +3,5 @@
"version": "10.0.100",
"rollForward": "minor",
"allowPrerelease": false
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
+4 -4
View File
@@ -2,11 +2,11 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<RCNumber>3</RCNumber>
<RCNumber>2</RCNumber>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260304.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260304.1</PackageVersion>
<GitTag>1.0.0-rc3</GitTag>
<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>
@@ -60,7 +60,7 @@ Console.WriteLine();
// Submit the red team run to the service
Console.WriteLine("Submitting red team run...");
RedTeam redTeamRun = await aiProjectClient.RedTeams.CreateAsync(redTeamConfig, options: null);
RedTeam redTeamRun = await aiProjectClient.RedTeams.CreateAsync(redTeamConfig);
Console.WriteLine($"Red team run created: {redTeamRun.Name}");
Console.WriteLine($"Status: {redTeamRun.Status}");
@@ -35,7 +35,7 @@ string userScope = $"user_{Environment.MachineName}";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
// Create the Memory Search tool configuration
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope)
MemorySearchTool memorySearchTool = new(memoryStoreName, userScope)
{
// Optional: Configure how quickly new memories are indexed (in seconds)
UpdateDelay = 1,
@@ -88,9 +88,7 @@ internal sealed class Program
{
string workflowYaml = File.ReadAllText("MathChat.yaml");
#pragma warning disable AAIP001 // WorkflowAgentDefinition is experimental
WorkflowAgentDefinition workflowAgentDefinition = WorkflowAgentDefinition.FromYaml(workflowYaml);
#pragma warning restore AAIP001
return
await agentClient.CreateAgentAsync(
@@ -75,15 +75,10 @@ string apiKey = builder.Configuration["OPENAI_API_KEY"]
?? throw new InvalidOperationException("Set the OPENAI_API_KEY environment variable.");
string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini";
// Here we are using Singleton lifetime, since none of the services, function tools and user context classes in the sample have state that are per request.
// You should evaluate the appropriate lifetime for your own services and tools based on their behavior and dependencies.
// E.g. if any of the service instances or tools maintain state that is specific to a user, and each request may be from a different user,
// you should use Scoped lifetime instead, so that a new instance is created for each request.
// Note that if you use Scoped lifetime for any dependencies, you must also use Scoped lifetime for any class that uses it, including the agent itself.
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IUserContext, KeycloakUserContext>();
builder.Services.AddSingleton<ExpenseService>();
builder.Services.AddSingleton<AIAgent>(sp =>
builder.Services.AddScoped<IUserContext, KeycloakUserContext>();
builder.Services.AddScoped<ExpenseService>();
builder.Services.AddScoped<AIAgent>(sp =>
{
var expenseService = sp.GetRequiredService<ExpenseService>();
@@ -27,73 +27,43 @@ public interface IUserContext
/// Keycloak uses <c>sub</c> for the user ID, <c>preferred_username</c>
/// for the login name, <c>given_name</c>/<c>family_name</c> for the
/// display name, and <c>scope</c> (space-delimited) for granted scopes.
/// Registered as a singleton — claims are parsed once per request and
/// cached in <see cref="HttpContext.Items"/>.
/// Registered as a scoped service so it is resolved once per request.
/// </summary>
public sealed class KeycloakUserContext : IUserContext
{
private static readonly object s_cacheKey = new();
public string UserId { get; }
private readonly IHttpContextAccessor _httpContextAccessor;
public string UserName { get; }
public string DisplayName { get; }
public IReadOnlySet<string> Scopes { get; }
public KeycloakUserContext(IHttpContextAccessor httpContextAccessor)
{
this._httpContextAccessor = httpContextAccessor;
}
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
public string UserId => this.GetOrCreateCachedInfo().UserId;
this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? user?.FindFirstValue("sub")
?? "anonymous";
public string UserName => this.GetOrCreateCachedInfo().UserName;
public string DisplayName => this.GetOrCreateCachedInfo().DisplayName;
public IReadOnlySet<string> Scopes => this.GetOrCreateCachedInfo().Scopes;
private CachedUserInfo GetOrCreateCachedInfo()
{
HttpContext? httpContext = this._httpContextAccessor.HttpContext;
if (httpContext is not null && httpContext.Items.TryGetValue(s_cacheKey, out object? cached) && cached is CachedUserInfo info)
{
return info;
}
info = ParseClaims(httpContext?.User);
if (httpContext is not null)
{
httpContext.Items[s_cacheKey] = info;
}
return info;
}
private static CachedUserInfo ParseClaims(ClaimsPrincipal? user)
{
string userId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? user?.FindFirstValue("sub")
?? "anonymous";
string userName = user?.FindFirstValue("preferred_username")
?? user?.FindFirstValue(ClaimTypes.Name)
?? "unknown";
this.UserName = user?.FindFirstValue("preferred_username")
?? user?.FindFirstValue(ClaimTypes.Name)
?? "unknown";
string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName);
string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname);
string displayName = (givenName, familyName) switch
this.DisplayName = (givenName, familyName) switch
{
(not null, not null) => $"{givenName} {familyName}",
(not null, null) => givenName,
(null, not null) => familyName,
_ => userName,
_ => this.UserName,
};
string? scopeClaim = user?.FindFirstValue("scope");
IReadOnlySet<string> scopes = scopeClaim is not null
this.Scopes = scopeClaim is not null
? new HashSet<string>(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return new CachedUserInfo(userId, userName, displayName, scopes);
}
private sealed record CachedUserInfo(string UserId, string UserName, string DisplayName, IReadOnlySet<string> Scopes);
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -36,10 +36,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -11,10 +11,9 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location)
@@ -23,19 +22,17 @@ static string GetWeather([Description("The location to get the weather for.")] s
// Create the chat client and agent.
// Note: ApprovalRequiredAIFunction wraps the tool to require user approval before invocation.
// User should reply with 'approve' or 'reject' when prompted.
// 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.
#pragma warning disable MEAI001 // Type is for evaluation purposes only
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
new AzureCliCredential())
.GetChatClient(deploymentName)
.AsAIAgent(
.AsIChatClient()
.CreateAIAgent(
instructions: "You are a helpful assistant",
tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]
);
#pragma warning restore MEAI001
InMemoryAgentThreadRepository threadRepository = new(agent);
var threadRepository = new InMemoryAgentThreadRepository(agent);
await agent.RunAIAgentAsync(telemetrySourceName: "Agents", threadRepository: threadRepository);
@@ -35,10 +35,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.6" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -9,10 +9,9 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// Create an MCP tool that can be called without approval.
AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAddress: "https://learn.microsoft.com/api/mcp")
@@ -29,7 +28,8 @@ AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetResponsesClient(deploymentName)
.AsAIAgent(
.AsIChatClient()
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgent",
tools: [mcpTool]);
@@ -18,7 +18,7 @@ Before running this sample, ensure you have:
2. A deployment of a chat model (e.g., gpt-4o-mini)
3. Azure CLI installed and authenticated
**Note**: This sample uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
**Note**: This sample uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
## Environment Variables
@@ -36,11 +36,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -15,21 +15,21 @@ using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
Console.WriteLine($"Project Endpoint: {endpoint}");
Console.WriteLine($"Model Deployment: {deploymentName}");
Hotel[] seattleHotels =
[
var seattleHotels = new[]
{
new Hotel("Contoso Suites", 189, 4.5, "Downtown"),
new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"),
new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"),
new Hotel("Relecloud Hotel", 99, 3.8, "University District"),
];
};
[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")]
string GetAvailableHotels(
@@ -54,21 +54,21 @@ string GetAvailableHotels(
return "Error: Check-out date must be after check-in date.";
}
int nights = (checkOut - checkIn).Days;
List<Hotel> availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
var nights = (checkOut - checkIn).Days;
var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
if (availableHotels.Count == 0)
{
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
}
StringBuilder result = new();
var result = new StringBuilder();
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
result.AppendLine();
foreach (Hotel hotel in availableHotels)
foreach (var hotel in availableHotels)
{
int totalCost = hotel.PricePerNight * nights;
var totalCost = hotel.PricePerNight * nights;
result.AppendLine($"**{hotel.Name}**");
result.AppendLine($" Location: {hotel.Location}");
result.AppendLine($" Rating: {hotel.Rating}/5");
@@ -84,10 +84,7 @@ string GetAvailableHotels(
}
}
// 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.
DefaultAzureCredential credential = new();
var credential = new AzureCliCredential();
AIProjectClient projectClient = new(new Uri(endpoint), credential);
ClientConnection connection = projectClient.GetConnection(typeof(AzureOpenAIClient).FullName!);
@@ -99,14 +96,14 @@ if (!connection.TryGetLocatorAsUri(out Uri? openAiEndpoint) || openAiEndpoint is
openAiEndpoint = new Uri($"https://{openAiEndpoint.Host}");
Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}");
IChatClient chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
var chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false)
.Build();
AIAgent agent = chatClient.AsAIAgent(
var agent = new ChatClientAgent(chatClient,
name: "SeattleHotelAgent",
instructions: """
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
@@ -35,10 +35,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -11,8 +11,8 @@ using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
TextSearchProviderOptions textSearchOptions = new()
{
@@ -28,13 +28,13 @@ AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(new ChatClientAgentOptions
.CreateAIAgent(new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)]
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
await agent.RunAIAgentAsync();
@@ -35,10 +35,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -9,16 +9,13 @@ using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
string toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
var openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
// 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.
DefaultAzureCredential credential = new();
var credential = new AzureCliCredential();
IChatClient chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
@@ -26,7 +23,7 @@ IChatClient chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credenti
.UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true)
.Build();
AIAgent agent = chatClient.AsAIAgent(
var agent = new ChatClientAgent(chatClient,
name: "AgentWithTools",
instructions: @"You are a helpful assistant with access to tools for fetching Microsoft documentation.
@@ -6,7 +6,7 @@ Key features:
- Configuring Foundry tools using `UseFoundryTools` with MCP and code interpreter
- Connecting to an external MCP tool via a Foundry project connection
- Using `DefaultAzureCredential` for Azure authentication
- Using `AzureCliCredential` for Azure authentication
- OpenTelemetry instrumentation for both the chat client and agent
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
@@ -36,7 +36,7 @@ $env:MCP_TOOL_CONNECTION_ID="SampleMCPTool"
## How It Works
1. An `AzureOpenAIClient` is created with `DefaultAzureCredential` and used to get a chat client
1. An `AzureOpenAIClient` is created with `AzureCliCredential` and used to get a chat client
2. The chat client is wrapped with `UseFoundryTools` which registers two Foundry tool types:
- **MCP connection**: Connects to an external MCP server (Microsoft Learn) via the project connection name, providing documentation fetch and search capabilities
- **Code interpreter**: Allows the agent to execute code snippets when needed
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -35,10 +35,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -12,8 +12,8 @@ using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
// Set up the Azure OpenAI client
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -32,9 +32,9 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
.AddEdge(frenchAgent, spanishAgent)
.AddEdge(spanishAgent, englishAgent)
.Build()
.AsAIAgent();
.AsAgent();
await agent.RunAIAgentAsync();
static AIAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
chatClient.AsAIAgent($"You are a translation assistant that translates the provided text to {targetLanguage}.");
static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}.");
@@ -19,7 +19,7 @@ Before you begin, ensure you have the following prerequisites:
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
**Note**: This demo uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
Set the following environment variables:
@@ -1,20 +0,0 @@
# Build the application
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# Copy files from the current directory on the host to the working directory in the container
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
RUN dotnet publish -c Release --no-build -o /app -f net10.0
# Run the application
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
# Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
EXPOSE 8088
ENTRYPOINT ["dotnet", "FoundryMultiAgent.dll"]
@@ -1,76 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!--
Disable central package management for this project.
This project requires explicit package references with versions specified inline rather than
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
be created from this project, and the Docker build process only has access to this folder
and cannot access parent folders where Directory.Packages.props resides.
-->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<!--
Remove analyzer PackageReference items inherited from Directory.Packages.props.
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
and the PackageReference items are always inherited through MSBuild imports regardless of the
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
-->
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Agents.AI.AzureAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251219.1" />
<PackageReference Include="OpenTelemetry" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,49 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a multi-agent workflow with Writer and Reviewer agents
// using Azure AI Foundry AIProjectClient and the Agent Framework WorkflowBuilder.
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
Console.WriteLine($"Using Azure AI endpoint: {endpoint}");
Console.WriteLine($"Using model deployment: {deploymentName}");
// 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 Foundry agents
AIAgent writerAgent = await aiProjectClient.CreateAIAgentAsync(
name: "Writer",
model: deploymentName,
instructions: "You are an excellent content writer. You create new content and edit contents based on the feedback.");
AIAgent reviewerAgent = await aiProjectClient.CreateAIAgentAsync(
name: "Reviewer",
model: deploymentName,
instructions: "You are an excellent content reviewer. Provide actionable feedback to the writer about the provided content. Provide the feedback in the most concise manner possible.");
try
{
var workflow = new WorkflowBuilder(writerAgent)
.AddEdge(writerAgent, reviewerAgent)
.Build();
Console.WriteLine("Starting Writer-Reviewer Workflow Agent Server on http://localhost:8088");
await workflow.AsAgent().RunAIAgentAsync();
}
finally
{
// Cleanup server-side agents
await aiProjectClient.Agents.DeleteAgentAsync(writerAgent.Name);
await aiProjectClient.Agents.DeleteAgentAsync(reviewerAgent.Name);
}
@@ -1,168 +0,0 @@
**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md).
Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct.
Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates.
Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output.
# What this sample demonstrates
This sample demonstrates a **key advantage of code-based hosted agents**:
- **Multi-agent workflows** - Orchestrate multiple agents working together
Code-based agents can execute **any C# code** you write. This sample includes a Writer-Reviewer workflow where two agents collaborate: a Writer creates content and a Reviewer provides feedback.
The agent is hosted using the [Azure AI AgentServer SDK](https://www.nuget.org/packages/Azure.AI.AgentServer.AgentFramework/) and can be deployed to Microsoft Foundry.
## How It Works
### Multi-Agent Workflow
In [Program.cs](Program.cs), the sample creates two agents using `AIProjectClient.CreateAIAgentAsync()` from the [Microsoft.Agents.AI.AzureAI](https://www.nuget.org/packages/Microsoft.Agents.AI.AzureAI/) package:
- **Writer** - An agent that creates and edits content based on feedback
- **Reviewer** - An agent that provides actionable feedback on the content
The `WorkflowBuilder` from the [Microsoft.Agents.AI.Workflows](https://www.nuget.org/packages/Microsoft.Agents.AI.Workflows/) package connects these agents in a sequential flow:
1. The Writer receives the initial request and generates content
2. The Reviewer evaluates the content and provides feedback
3. Both agent responses are output to the user
### Agent Hosting
The agent is hosted using the [Azure AI AgentServer SDK](https://www.nuget.org/packages/Azure.AI.AgentServer.AgentFramework/),
which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
## Running the Agent Locally
### Prerequisites
Before running this sample, ensure you have:
1. **Azure AI Foundry Project**
- Project created.
- Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`)
- Note your project endpoint URL and model deployment name
> **Note**: You can right-click the project in the Microsoft Foundry VS Code extension and select `Copy Project Endpoint URL` to get the endpoint.
2. **Azure CLI**
- Installed and authenticated
- Run `az login` and verify with `az account show`
- Your identity needs the **Azure AI Developer** role on the Foundry resource (for `agents/write` data action required by `CreateAIAgentAsync`)
3. **.NET 10.0 SDK or later**
- Verify your version: `dotnet --version`
- Download from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download)
### Environment Variables
Set the following environment variables:
**PowerShell:**
```powershell
# Replace with your actual values
$env:AZURE_AI_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
$env:MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
**Bash:**
```bash
export AZURE_AI_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
export MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
### Running the Sample
To run the agent, execute the following command in your terminal:
```bash
dotnet restore
dotnet build
dotnet run
```
This will start the hosted agent locally on `http://localhost:8088/`.
### Interacting with the Agent
**VS Code:**
1. Open the Visual Studio Code Command Palette and execute the `Microsoft Foundry: Open Container Agent Playground Locally` command.
2. Execute the following commands to start the containerized hosted agent.
```bash
dotnet restore
dotnet build
dotnet run
```
3. Submit a request to the agent through the playground interface. For example, you may enter a prompt such as: "Create a slogan for a new electric SUV that is affordable and fun to drive."
4. Review the agent's response in the playground interface.
> **Note**: Open the local playground before starting the container agent to ensure the visualization functions correctly.
**PowerShell (Windows):**
```powershell
$body = @{
input = "Create a slogan for a new electric SUV that is affordable and fun to drive"
stream = $false
} | ConvertTo-Json
Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json"
```
**Bash/curl (Linux/macOS):**
```bash
curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \
-d '{"input": "Create a slogan for a new electric SUV that is affordable and fun to drive","stream":false}'
```
You can also use the `run-requests.http` file in this directory with the VS Code REST Client extension.
The Writer agent will generate content based on your prompt, and the Reviewer agent will provide feedback on the output.
## Deploying the Agent to Microsoft Foundry
**Preparation (required)**
Please check the environment_variables section in [agent.yaml](agent.yaml) and ensure the variables there are set in your target Microsoft Foundry Project.
To deploy the hosted agent:
1. Open the VS Code Command Palette and run the `Microsoft Foundry: Deploy Hosted Agent` command.
2. Follow the interactive deployment prompts. The extension will help you select or create the container files it needs.
3. After deployment completes, the hosted agent appears under the `Hosted Agents (Preview)` section of the extension tree. You can select the agent there to view details and test it using the integrated playground.
**What the deploy flow does for you:**
- Creates or obtains an Azure Container Registry for the target project.
- Builds and pushes a container image from your workspace (the build packages the workspace respecting `.dockerignore`).
- Creates an agent version in Microsoft Foundry using the built image. If a `.env` file exists at the workspace root, the extension will parse it and include its key/value pairs as the hosted agent's environment variables in the create request (these variables will be available to the agent runtime).
- Starts the agent container on the project's capability host. If the capability host is not provisioned, the extension will prompt you to enable it and will guide you through creating it.
## MSI Configuration in the Azure Portal
This sample requires the Microsoft Foundry Project to authenticate using a Managed Identity when running remotely in Azure. Grant the project's managed identity the required permissions by assigning the built-in [Azure AI User](https://aka.ms/foundry-ext-project-role) role.
To configure the Managed Identity:
1. In the Azure Portal, open the Foundry Project.
2. Select "Access control (IAM)" from the left-hand menu.
3. Click "Add" and choose "Add role assignment".
4. In the role selection, search for and select "Azure AI User", then click "Next".
5. For "Assign access to", choose "Managed identity".
6. Click "Select members", locate the managed identity associated with your Foundry Project (you can search by the project name), then click "Select".
7. Click "Review + assign" to complete the assignment.
8. Allow a few minutes for the role assignment to propagate before running the application.
## Additional Resources
- [Microsoft Agents Framework](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview)
- [Managed Identities for Azure Resources](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/)
@@ -1,31 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
name: FoundryMultiAgent
displayName: "Foundry Multi-Agent Workflow"
description: >
A multi-agent workflow featuring a Writer and Reviewer that collaborate
to create and refine content using Azure AI Foundry PersistentAgentsClient.
metadata:
authors:
- Microsoft Agent Framework Team
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Multi-Agent Workflow
- Writer-Reviewer
- Content Creation
template:
kind: hosted
name: FoundryMultiAgent
protocols:
- protocol: responses
version: v1
environment_variables:
- name: AZURE_AI_PROJECT_ENDPOINT
value: ${AZURE_AI_PROJECT_ENDPOINT}
- name: MODEL_DEPLOYMENT_NAME
value: gpt-4o-mini
resources:
- name: "gpt-4o-mini"
kind: model
id: gpt-4o-mini
@@ -1,4 +0,0 @@
{
"AZURE_AI_PROJECT_ENDPOINT": "https://<your-resource>.services.ai.azure.com/api/projects/<your-project>",
"MODEL_DEPLOYMENT_NAME": "gpt-4o-mini"
}
@@ -1,34 +0,0 @@
@host = http://localhost:8088
@endpoint = {{host}}/responses
### Health Check
GET {{host}}/readiness
### Simple string input - Content creation request
POST {{endpoint}}
Content-Type: application/json
{
"input": "Create a slogan for a new electric SUV that is affordable and fun to drive",
"stream": false
}
### Explicit input format
POST {{endpoint}}
Content-Type: application/json
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "Write a short product description for a smart water bottle that tracks hydration"
}
]
}
],
"stream": false
}
@@ -1,20 +0,0 @@
# Build the application
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# Copy files from the current directory on the host to the working directory in the container
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
RUN dotnet publish -c Release --no-build -o /app -f net10.0
# Run the application
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
# Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
EXPOSE 8088
ENTRYPOINT ["dotnet", "FoundrySingleAgent.dll"]
@@ -1,67 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!--
Disable central package management for this project.
This project requires explicit package references with versions specified inline rather than
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
be created from this project, and the Docker build process only has access to this folder
and cannot access parent folders where Directory.Packages.props resides.
-->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<!--
Remove analyzer PackageReference items inherited from Directory.Packages.props.
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
and the PackageReference items are always inherited through MSBuild imports regardless of the
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
-->
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Agents.AI.AzureAI" Version="1.0.0-preview.251219.1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -1,128 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle.
// Uses Microsoft Agent Framework with Azure AI Foundry.
// Ready for deployment to Foundry Hosted Agent service.
using System.ComponentModel;
using System.Globalization;
using System.Text;
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
// Get configuration from environment variables
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
Console.WriteLine($"Project Endpoint: {endpoint}");
Console.WriteLine($"Model Deployment: {deploymentName}");
// Simulated hotel data for Seattle
var seattleHotels = new[]
{
new Hotel("Contoso Suites", 189, 4.5, "Downtown"),
new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"),
new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"),
new Hotel("Relecloud Hotel", 99, 3.8, "University District"),
};
[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")]
string GetAvailableHotels(
[Description("Check-in date in YYYY-MM-DD format")] string checkInDate,
[Description("Check-out date in YYYY-MM-DD format")] string checkOutDate,
[Description("Maximum price per night in USD (optional, defaults to 500)")] int maxPrice = 500)
{
try
{
// Parse dates
if (!DateTime.TryParseExact(checkInDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkIn))
{
return "Error parsing check-in date. Please use YYYY-MM-DD format.";
}
if (!DateTime.TryParseExact(checkOutDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkOut))
{
return "Error parsing check-out date. Please use YYYY-MM-DD format.";
}
// Validate dates
if (checkOut <= checkIn)
{
return "Error: Check-out date must be after check-in date.";
}
var nights = (checkOut - checkIn).Days;
// Filter hotels by price
var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
if (availableHotels.Count == 0)
{
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
}
// Build response
var result = new StringBuilder();
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
result.AppendLine();
foreach (var hotel in availableHotels)
{
var totalCost = hotel.PricePerNight * nights;
result.AppendLine($"**{hotel.Name}**");
result.AppendLine($" Location: {hotel.Location}");
result.AppendLine($" Rating: {hotel.Rating}/5");
result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})");
result.AppendLine();
}
return result.ToString();
}
catch (Exception ex)
{
return $"Error processing request. Details: {ex.Message}";
}
}
// 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 Foundry agent with hotel search tool
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(
name: "SeattleHotelAgent",
model: deploymentName,
instructions: """
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
When a user asks about hotels in Seattle:
1. Ask for their check-in and check-out dates if not provided
2. Ask about their budget preferences if not mentioned
3. Use the GetAvailableHotels tool to find available options
4. Present the results in a friendly, informative way
5. Offer to help with additional questions about the hotels or Seattle
Be conversational and helpful. If users ask about things outside of Seattle hotels,
politely let them know you specialize in Seattle hotel recommendations.
""",
tools: [AIFunctionFactory.Create(GetAvailableHotels)]);
try
{
Console.WriteLine("Seattle Hotel Agent Server running on http://localhost:8088");
await agent.RunAIAgentAsync(telemetrySourceName: "Agents");
}
finally
{
// Cleanup server-side agent
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
}
// Hotel record for simulated data
internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location);
@@ -1,167 +0,0 @@
**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md).
Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct.
Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates.
Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output.
# What this sample demonstrates
This sample demonstrates a **key advantage of code-based hosted agents**:
- **Local C# tool execution** - Run custom C# methods as agent tools
Code-based agents can execute **any C# code** you write. This sample includes a Seattle Hotel Agent with a `GetAvailableHotels` tool that searches for available hotels based on check-in/check-out dates and budget preferences.
The agent is hosted using the [Azure AI AgentServer SDK](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/ai.agentserver.agentframework-readme) and can be deployed to Microsoft Foundry.
## How It Works
### Local Tools Integration
In [Program.cs](Program.cs), the agent uses `AIProjectClient.CreateAIAgentAsync()` from the [Microsoft.Agents.AI.AzureAI](https://www.nuget.org/packages/Microsoft.Agents.AI.AzureAI/) package to create a Foundry agent with a local C# method (`GetAvailableHotels`) that simulates a hotel availability API. This demonstrates how code-based agents can execute custom server-side logic that prompt agents cannot access.
The tool accepts:
- **checkInDate** - Check-in date in YYYY-MM-DD format
- **checkOutDate** - Check-out date in YYYY-MM-DD format
- **maxPrice** - Maximum price per night in USD (optional, defaults to $500)
### Agent Hosting
The agent is hosted using the [Azure AI AgentServer SDK](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/ai.agentserver.agentframework-readme),
which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
## Running the Agent Locally
### Prerequisites
Before running this sample, ensure you have:
1. **Azure AI Foundry Project**
- Project created.
- Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`)
- Note your project endpoint URL and model deployment name
2. **Azure CLI**
- Installed and authenticated
- Run `az login` and verify with `az account show`
- Your identity needs the **Azure AI Developer** role on the Foundry resource (for `agents/write` data action required by `CreateAIAgentAsync`)
3. **.NET 10.0 SDK or later**
- Verify your version: `dotnet --version`
- Download from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download)
### Environment Variables
Set the following environment variables (matching `agent.yaml`):
- `AZURE_AI_PROJECT_ENDPOINT` - Your Azure AI Foundry project endpoint URL (required)
- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4o-mini`)
**PowerShell:**
```powershell
# Replace with your actual values
$env:AZURE_AI_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
$env:MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
**Bash:**
```bash
export AZURE_AI_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
export MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
### Running the Sample
To run the agent, execute the following command in your terminal:
```bash
dotnet restore
dotnet build
dotnet run
```
This will start the hosted agent locally on `http://localhost:8088/`.
### Interacting with the Agent
**VS Code:**
1. Open the Visual Studio Code Command Palette and execute the `Microsoft Foundry: Open Container Agent Playground Locally` command.
2. Execute the following commands to start the containerized hosted agent.
```bash
dotnet restore
dotnet build
dotnet run
```
3. Submit a request to the agent through the playground interface. For example, you may enter a prompt such as: "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night."
4. The agent will use the GetAvailableHotels tool to search for available hotels matching your criteria.
> **Note**: Open the local playground before starting the container agent to ensure the visualization functions correctly.
**PowerShell (Windows):**
```powershell
$body = @{
input = "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under `$200 per night"
stream = $false
} | ConvertTo-Json
Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json"
```
**Bash/curl (Linux/macOS):**
```bash
curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \
-d '{"input": "Find me hotels in Seattle for March 20-23, 2025 under $200 per night","stream":false}'
```
You can also use the `run-requests.http` file in this directory with the VS Code REST Client extension.
The agent will use the `GetAvailableHotels` tool to search for available hotels matching your criteria.
## Deploying the Agent to Microsoft Foundry
**Preparation (required)**
Please check the environment_variables section in [agent.yaml](agent.yaml) and ensure the variables there are set in your target Microsoft Foundry Project.
To deploy the hosted agent:
1. Open the VS Code Command Palette and run the `Microsoft Foundry: Deploy Hosted Agent` command.
2. Follow the interactive deployment prompts. The extension will help you select or create the container files it needs.
3. After deployment completes, the hosted agent appears under the `Hosted Agents (Preview)` section of the extension tree. You can select the agent there to view details and test it using the integrated playground.
**What the deploy flow does for you:**
- Creates or obtains an Azure Container Registry for the target project.
- Builds and pushes a container image from your workspace (the build packages the workspace respecting `.dockerignore`).
- Creates an agent version in Microsoft Foundry using the built image. If a `.env` file exists at the workspace root, the extension will parse it and include its key/value pairs as the hosted agent's environment variables in the create request (these variables will be available to the agent runtime).
- Starts the agent container on the project's capability host. If the capability host is not provisioned, the extension will prompt you to enable it and will guide you through creating it.
## MSI Configuration in the Azure Portal
This sample requires the Microsoft Foundry Project to authenticate using a Managed Identity when running remotely in Azure. Grant the project's managed identity the required permissions by assigning the built-in [Azure AI User](https://aka.ms/foundry-ext-project-role) role.
To configure the Managed Identity:
1. In the Azure Portal, open the Foundry Project.
2. Select "Access control (IAM)" from the left-hand menu.
3. Click "Add" and choose "Add role assignment".
4. In the role selection, search for and select "Azure AI User", then click "Next".
5. For "Assign access to", choose "Managed identity".
6. Click "Select members", locate the managed identity associated with your Foundry Project (you can search by the project name), then click "Select".
7. Click "Review + assign" to complete the assignment.
8. Allow a few minutes for the role assignment to propagate before running the application.
## Additional Resources
- [Microsoft Agents Framework](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview)
- [Managed Identities for Azure Resources](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/)
@@ -1,32 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
name: FoundrySingleAgent
displayName: "Foundry Single Agent with Local Tools"
description: >
A travel assistant agent that helps users find hotels in Seattle.
Demonstrates local C# tool execution - a key advantage of code-based
hosted agents over prompt agents.
metadata:
authors:
- Microsoft Agent Framework Team
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Local Tools
- Travel Assistant
- Hotel Search
template:
kind: hosted
name: FoundrySingleAgent
protocols:
- protocol: responses
version: v1
environment_variables:
- name: AZURE_AI_PROJECT_ENDPOINT
value: ${AZURE_AI_PROJECT_ENDPOINT}
- name: MODEL_DEPLOYMENT_NAME
value: gpt-4o-mini
resources:
- name: "gpt-4o-mini"
kind: model
id: gpt-4o-mini
@@ -1,52 +0,0 @@
@host = http://localhost:8088
@endpoint = {{host}}/responses
### Health Check
GET {{host}}/readiness
### Simple hotel search - budget under $200
POST {{endpoint}}
Content-Type: application/json
{
"input": "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night",
"stream": false
}
### Hotel search with higher budget
POST {{endpoint}}
Content-Type: application/json
{
"input": "Find me hotels in Seattle for March 20-23, 2025 under $250 per night",
"stream": false
}
### Ask for recommendations without dates (agent should ask for clarification)
POST {{endpoint}}
Content-Type: application/json
{
"input": "What hotels do you recommend in Seattle?",
"stream": false
}
### Explicit input format
POST {{endpoint}}
Content-Type: application/json
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "I'm looking for a hotel in Seattle from 2025-04-01 to 2025-04-05, my budget is $150 per night maximum"
}
]
}
],
"stream": false
}
@@ -12,8 +12,6 @@ These samples demonstrate how to build and host AI agents using the [Azure AI Ag
| [`AgentWithHostedMCP`](./AgentWithHostedMCP/) | Hosted MCP server tool (Microsoft Learn search) |
| [`AgentWithTextSearchRag`](./AgentWithTextSearchRag/) | RAG with `TextSearchProvider` (Contoso Outdoors) |
| [`AgentsInWorkflows`](./AgentsInWorkflows/) | Sequential workflow pipeline (translation chain) |
| [`FoundryMultiAgent`](./FoundryMultiAgent/) | Multi-agent Writer-Reviewer workflow using `AIProjectClient.CreateAIAgentAsync()` from [Microsoft.Agents.AI.AzureAI](https://www.nuget.org/packages/Microsoft.Agents.AI.AzureAI/) |
| [`FoundrySingleAgent`](./FoundrySingleAgent/) | Single agent with local C# tool execution (hotel search) using `AIProjectClient.CreateAIAgentAsync()` from [Microsoft.Agents.AI.AzureAI](https://www.nuget.org/packages/Microsoft.Agents.AI.AzureAI/) |
## Common Prerequisites
@@ -25,7 +23,7 @@ Before running any sample, ensure you have:
### Authenticate with Azure CLI
All samples use `DefaultAzureCredential` for authentication, which automatically probes multiple credential sources (environment variables, managed identity, Azure CLI, etc.). For local development, the simplest approach is to authenticate via Azure CLI:
All samples use `AzureCliCredential` for authentication. Make sure you're logged in:
```powershell
az login
@@ -40,9 +38,9 @@ Most samples require one or more of these environment variables:
|----------|---------|-------------|
| `AZURE_OPENAI_ENDPOINT` | Most samples | Your Azure OpenAI resource endpoint URL |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Most samples | Chat model deployment name (defaults to `gpt-4o-mini`) |
| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithTools, AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Azure AI Foundry project endpoint |
| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithTools, AgentWithLocalTools | Azure AI Foundry project endpoint |
| `MCP_TOOL_CONNECTION_ID` | AgentWithTools | Foundry MCP tool connection name |
| `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Chat model deployment name (defaults to `gpt-4o-mini`) |
| `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools | Chat model deployment name (defaults to `gpt-4o-mini`) |
See each sample's README for the specific variables required.
@@ -39,7 +39,7 @@ public static partial class AzureAIProjectChatClientExtensions
/// <exception cref="InvalidOperationException">The agent with the specified name was not found.</exception>
/// <remarks>
/// When instantiating a <see cref="ChatClientAgent"/> by using an <see cref="AgentReference"/>, minimal information will be available about the agent in the instance level, and any logic that relies
/// on <see cref="AIAgent.GetService{TService}(object?)"/> to retrieve information about the agent like <see cref="AgentVersion" /> will receive <see langword="null"/> as the result.
/// on <see cref="AIAgent.GetService(Type, object?)"/> to retrieve information about the agent like <see cref="AgentVersion" /> will receive <see langword="null"/> as the result.
/// </remarks>
public static ChatClientAgent AsAIAgent(
this AIProjectClient aiProjectClient,
@@ -355,27 +355,28 @@ public static partial class AzureAIProjectChatClientExtensions
private static readonly ModelReaderWriterOptions s_modelWriterOptionsWire = new("W");
/// <summary>
/// Asynchronously retrieves an agent record by name using the protocol method to inject user-agent headers.
/// Asynchronously retrieves an agent record by name using the Protocol method with user-agent header.
/// </summary>
private static async Task<AgentRecord> GetAgentRecordByNameAsync(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken)
{
ClientResult protocolResponse = await aiProjectClient.Agents.GetAgentAsync(agentName, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false);
var rawResponse = protocolResponse.GetRawResponse();
AgentRecord? result = ModelReaderWriter.Read<AgentRecord>(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default);
return result ?? throw new InvalidOperationException($"Agent with name '{agentName}' not found.");
return ClientResult.FromOptionalValue(result, rawResponse).Value!
?? throw new InvalidOperationException($"Agent with name '{agentName}' not found.");
}
/// <summary>
/// Asynchronously creates an agent version using the protocol method to inject user-agent headers.
/// Asynchronously creates an agent version using the Protocol method with user-agent header.
/// </summary>
private static async Task<AgentVersion> CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken)
{
BinaryData serializedOptions = ModelReaderWriter.Write(creationOptions, s_modelWriterOptionsWire, AzureAIProjectsContext.Default);
BinaryContent content = BinaryContent.Create(serializedOptions);
ClientResult protocolResponse = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, content, foundryFeatures: null, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false);
using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions, ModelReaderWriterOptions.Json, AzureAIProjectsContext.Default));
ClientResult protocolResponse = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, protocolRequest, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false);
var rawResponse = protocolResponse.GetRawResponse();
AgentVersion? result = ModelReaderWriter.Read<AgentVersion>(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default);
return result ?? throw new InvalidOperationException($"Failed to create agent version for agent '{agentName}'.");
return ClientResult.FromValue(result, rawResponse).Value!;
}
private static async Task<ChatClientAgent> CreateAIAgentAsync(
@@ -346,14 +346,14 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
};
}
internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
{
AIContent content = new()
TextContent textContent = new(assistantMessage.Data?.Content ?? string.Empty)
{
RawRepresentation = assistantMessage
};
return new AgentResponseUpdate(ChatRole.Assistant, [content])
return new AgentResponseUpdate(ChatRole.Assistant, [textContent])
{
AgentId = this.Id,
ResponseId = assistantMessage.Data?.MessageId,
@@ -19,10 +19,9 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -31,7 +30,7 @@ public static class AgentHostingServiceCollectionExtensions
var chatClient = sp.GetRequiredService<IChatClient>();
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
}, lifetime);
});
}
/// <summary>
@@ -41,10 +40,9 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -52,7 +50,7 @@ public static class AgentHostingServiceCollectionExtensions
{
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
}, lifetime);
});
}
/// <summary>
@@ -62,10 +60,9 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -74,7 +71,7 @@ public static class AgentHostingServiceCollectionExtensions
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
}, lifetime);
});
}
/// <summary>
@@ -85,10 +82,9 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="description">A description of the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey)
{
Throw.IfNull(services);
Throw.IfNullOrEmpty(name);
@@ -97,7 +93,7 @@ public static class AgentHostingServiceCollectionExtensions
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
var tools = sp.GetKeyedServices<AITool>(name).ToList();
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description, tools: tools);
}, lifetime);
});
}
/// <summary>
@@ -106,16 +102,15 @@ public static class AgentHostingServiceCollectionExtensions
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns <see langword="null"/> or an agent whose <see cref="AIAgent.Name"/> does not match <paramref name="name"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
{
Throw.IfNull(services);
Throw.IfNull(name);
Throw.IfNull(createAgentDelegate);
services.AddKeyedService(name, (sp, key) =>
services.AddKeyedSingleton(name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
@@ -127,18 +122,8 @@ public static class AgentHostingServiceCollectionExtensions
}
return agent;
}, lifetime);
});
return new HostedAgentBuilder(name, services, lifetime);
}
/// <summary>
/// Registers a keyed service with the specified lifetime.
/// </summary>
internal static void AddKeyedService<T>(this IServiceCollection services, object? serviceKey, Func<IServiceProvider, object?, T> factory, ServiceLifetime lifetime)
where T : class
{
var descriptor = new ServiceDescriptor(typeof(T), serviceKey, (sp, key) => factory(sp, key), lifetime);
services.Add(descriptor);
return new HostedAgentBuilder(name, services);
}
}
@@ -2,7 +2,6 @@
using System;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Shared.Diagnostics;
@@ -19,13 +18,12 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions)
{
Throw.IfNull(builder);
return builder.Services.AddAIAgent(name, instructions, lifetime);
return builder.Services.AddAIAgent(name, instructions);
}
/// <summary>
@@ -35,14 +33,13 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
return builder.Services.AddAIAgent(name, instructions, chatClient, lifetime);
return builder.Services.AddAIAgent(name, instructions, chatClient);
}
/// <summary>
@@ -53,14 +50,13 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="description">A description of the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey, lifetime);
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey);
}
/// <summary>
@@ -70,13 +66,12 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey)
{
Throw.IfNull(builder);
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey, lifetime);
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey);
}
/// <summary>
@@ -85,13 +80,12 @@ public static class HostApplicationBuilderAgentExtensions
/// <param name="builder">The host application builder to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns null or an invalid AI agent instance.</exception>
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
{
Throw.IfNull(builder);
return builder.Services.AddAIAgent(name, createAgentDelegate, lifetime);
return builder.Services.AddAIAgent(name, createAgentDelegate);
}
}
@@ -19,20 +19,19 @@ public static class HostApplicationBuilderWorkflowExtensions
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="name">The unique name for the workflow.</param>
/// <param name="createWorkflowDelegate">A factory function that creates the <see cref="Workflow"/> instance. The function receives the service provider and workflow name as parameters.</param>
/// <param name="lifetime">The DI service lifetime for the workflow registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createWorkflowDelegate"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is empty.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the factory delegate returns null or a workflow with a name that doesn't match the expected name.
/// </exception>
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate)
{
Throw.IfNull(builder);
Throw.IfNull(name);
Throw.IfNull(createWorkflowDelegate);
builder.Services.AddKeyedService(name, (sp, key) =>
builder.Services.AddKeyedSingleton(name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
@@ -44,7 +43,7 @@ public static class HostApplicationBuilderWorkflowExtensions
}
return workflow;
}, lifetime);
});
return new HostedWorkflowBuilder(name, builder);
}
@@ -9,17 +9,15 @@ internal sealed class HostedAgentBuilder : IHostedAgentBuilder
{
public string Name { get; }
public IServiceCollection ServiceCollection { get; }
public ServiceLifetime Lifetime { get; }
public HostedAgentBuilder(string name, IHostApplicationBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
: this(name, builder.Services, lifetime)
public HostedAgentBuilder(string name, IHostApplicationBuilder builder)
: this(name, builder.Services)
{
}
public HostedAgentBuilder(string name, IServiceCollection serviceCollection, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public HostedAgentBuilder(string name, IServiceCollection serviceCollection)
{
this.Name = name;
this.ServiceCollection = serviceCollection;
this.Lifetime = lifetime;
}
}
@@ -42,19 +42,17 @@ public static class HostedAgentBuilderExtensions
/// <param name="builder">The host agent builder to configure.</param>
/// <param name="createAgentSessionStore">A factory function that creates an agent session store instance using the provided service provider and agent
/// name.</param>
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore)
{
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
Throw.IfNullOrEmpty(keyString);
return createAgentSessionStore(sp, keyString) ??
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
}, lifetime);
});
return builder;
}
@@ -100,39 +98,13 @@ public static class HostedAgentBuilderExtensions
/// </summary>
/// <param name="builder">The hosted agent builder.</param>
/// <param name="factory">A factory function that creates a AI tool using the provided service provider.</param>
/// <param name="lifetime">The DI service lifetime for the tool registration. If <see langword="null"/>, the agent's lifetime is used.</param>
/// <returns>The same <see cref="IHostedAgentBuilder"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="factory"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the effective tool lifetime is shorter than the agent's lifetime, which would cause a captive dependency.
/// For example, a singleton agent cannot use scoped or transient tools.
/// </exception>
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory, ServiceLifetime? lifetime = null)
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory)
{
Throw.IfNull(builder);
Throw.IfNull(factory);
var effectiveLifetime = lifetime ?? builder.Lifetime;
ValidateToolLifetime(builder.Lifetime, effectiveLifetime);
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, name) => factory(sp), effectiveLifetime);
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, name) => factory(sp));
return builder;
}
/// <summary>
/// Validates that the tool lifetime is compatible with the agent lifetime.
/// A tool's lifetime must be at least as long as the agent's lifetime to prevent captive dependency issues.
/// </summary>
internal static void ValidateToolLifetime(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
{
// ServiceLifetime enum: Singleton=0, Scoped=1, Transient=2
// A higher value means a shorter lifetime.
if (toolLifetime > agentLifetime)
{
throw new InvalidOperationException(
$"A tool with lifetime '{toolLifetime}' cannot be registered for an agent with lifetime '{agentLifetime}'. " +
"The tool's lifetime must be at least as long as the agent's lifetime to avoid captive dependency issues.");
}
}
}
@@ -14,24 +14,22 @@ public static class HostedWorkflowBuilderExtensions
/// Registers the workflow as an AI agent in the dependency injection container.
/// </summary>
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
=> builder.AddAsAIAgent(name: null, lifetime: lifetime);
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder)
=> builder.AddAsAIAgent(name: null);
/// <summary>
/// Registers the workflow as an AI agent in the dependency injection container.
/// </summary>
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
/// <param name="name">The optional name for the AI agent. If not specified, the workflow name is used.</param>
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name, ServiceLifetime lifetime = ServiceLifetime.Singleton)
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name)
{
var workflowName = builder.Name;
var agentName = name ?? workflowName;
return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) =>
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key), lifetime);
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key));
}
}
@@ -18,9 +18,4 @@ public interface IHostedAgentBuilder
/// Gets the service collection for configuration.
/// </summary>
IServiceCollection ServiceCollection { get; }
/// <summary>
/// Gets the DI service lifetime used for the agent registration.
/// </summary>
ServiceLifetime Lifetime { get; }
}
@@ -747,15 +747,9 @@ public sealed partial class ChatClientAgent : AIAgent
{
// The agent has a ChatHistoryProvider configured, but the service returned a conversation id,
// meaning the service manages chat history server-side. Both cannot be used simultaneously.
if (this._agentOptions?.WarnOnChatHistoryProviderConflict is true
&& this._logger.IsEnabled(LogLevel.Warning))
if (this._agentOptions?.WarnOnChatHistoryProviderConflict is true)
{
var loggingAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientHistoryProviderConflict(
nameof(ChatClientAgentSession.ConversationId),
nameof(this.ChatHistoryProvider),
this.Id,
loggingAgentName);
this._logger.LogAgentChatClientHistoryProviderConflict(nameof(ChatClientAgentSession.ConversationId), nameof(this.ChatHistoryProvider), this.Id, this.GetLoggingAgentName());
}
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true)
@@ -350,38 +350,36 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
string? userId = searchScope.UserId;
string? sessionId = searchScope.SessionId;
// Build a combined filter using a single shared parameter to avoid expression tree
// scoping issues when multiple filters are combined with AndAlso.
ParameterExpression parameter = Expression.Parameter(typeof(Dictionary<string, object?>), "x");
Expression? filterBody = null;
Expression<Func<Dictionary<string, object?>, bool>>? filter = null;
if (applicationId != null)
{
filterBody = RebindFilterBody(x => (string?)x[ApplicationIdField] == applicationId, parameter);
filter = x => (string?)x[ApplicationIdField] == applicationId;
}
if (agentId != null)
{
Expression body = RebindFilterBody(x => (string?)x[AgentIdField] == agentId, parameter);
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId;
filter = filter == null ? agentIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
Expression.AndAlso(filter.Body, agentIdFilter.Body),
filter.Parameters);
}
if (userId != null)
{
Expression body = RebindFilterBody(x => (string?)x[UserIdField] == userId, parameter);
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x[UserIdField] == userId;
filter = filter == null ? userIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
Expression.AndAlso(filter.Body, userIdFilter.Body),
filter.Parameters);
}
if (sessionId != null)
{
Expression body = RebindFilterBody(x => (string?)x[SessionIdField] == sessionId, parameter);
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId;
filter = filter == null ? sessionIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
Expression.AndAlso(filter.Body, sessionIdFilter.Body),
filter.Parameters);
}
Expression<Func<Dictionary<string, object?>, bool>>? filter = filterBody != null
? Expression.Lambda<Func<Dictionary<string, object?>, bool>>(filterBody, parameter)
: null;
// Use search to find relevant messages
var searchResults = collection.SearchAsync(
queryText,
@@ -469,27 +467,6 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "<redacted>";
/// <summary>
/// Rebinds a filter expression's body to use the specified shared parameter,
/// replacing the original lambda parameter so that multiple filters can be safely
/// combined with <see cref="Expression.AndAlso(Expression, Expression)"/>.
/// </summary>
private static Expression RebindFilterBody(
Expression<Func<Dictionary<string, object?>, bool>> filter,
ParameterExpression sharedParameter)
{
return new ParameterReplacer(filter.Parameters[0], sharedParameter).Visit(filter.Body);
}
/// <summary>
/// An <see cref="ExpressionVisitor"/> that replaces one <see cref="ParameterExpression"/> with another.
/// </summary>
private sealed class ParameterReplacer(ParameterExpression original, ParameterExpression replacement) : ExpressionVisitor
{
protected override Expression VisitParameter(ParameterExpression node)
=> node == original ? replacement : base.VisitParameter(node);
}
/// <summary>
/// Represents the state of a <see cref="ChatHistoryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
@@ -17,9 +17,8 @@ namespace Microsoft.Agents.AI;
/// </summary>
/// <remarks>
/// Searches directories recursively (up to <see cref="MaxSearchDepth"/> levels) for SKILL.md files.
/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill
/// directory for files with matching extensions. Invalid resources are skipped with logged warnings.
/// Resource paths are checked against path traversal and symlink escape attacks.
/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded
/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks.
/// </remarks>
internal sealed partial class FileAgentSkillLoader
{
@@ -34,34 +33,33 @@ internal sealed partial class FileAgentSkillLoader
// Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Matches markdown links to local resource files. Group 1 = relative file path.
// Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
// Intentionally conservative: only matches paths with word characters, hyphens, dots,
// and forward slashes. Paths with spaces or special characters are not supported.
// Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json",
// [p](../shared/doc.txt) → "../shared/doc.txt"
private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
// Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _),
// "description: \"A skill\"" → (description, A skill, _)
private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Validates skill names: lowercase letters, numbers, and hyphens only;
// must not start or end with a hyphen; must not contain consecutive hyphens.
// Examples: "my-skill" âś“, "skill123" âś“, "-bad" âś—, "bad-" âś—, "Bad" âś—, "my--skill" âś—
private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled);
// Validates skill names: lowercase letters, numbers, and hyphens only; must not start or end with a hyphen.
// Examples: "my-skill" âś“, "skill123" âś“, "-bad" âś—, "bad-" âś—, "Bad" âś—
private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled);
private readonly ILogger _logger;
private readonly HashSet<string> _allowedResourceExtensions;
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillLoader"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="allowedResourceExtensions">File extensions to recognize as skill resources. When <see langword="null"/>, defaults are used.</param>
internal FileAgentSkillLoader(ILogger logger, IEnumerable<string>? allowedResourceExtensions = null)
internal FileAgentSkillLoader(ILogger logger)
{
this._logger = logger;
ValidateExtensions(allowedResourceExtensions);
this._allowedResourceExtensions = new HashSet<string>(
allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"],
StringComparer.OrdinalIgnoreCase);
}
/// <summary>
@@ -185,9 +183,9 @@ internal sealed partial class FileAgentSkillLoader
}
}
private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath)
private FileAgentSkill? ParseSkillFile(string skillDirectoryPath)
{
string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName);
string skillFilePath = Path.Combine(skillDirectoryPath, SkillFileName);
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
@@ -196,12 +194,17 @@ internal sealed partial class FileAgentSkillLoader
return null;
}
List<string> resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name);
List<string> resourceNames = ExtractResourcePaths(body);
if (!this.ValidateResources(skillDirectoryPath, resourceNames, frontmatter.Name))
{
return null;
}
return new FileAgentSkill(
frontmatter: frontmatter,
body: body,
sourcePath: skillDirectoryFullPath,
sourcePath: skillDirectoryPath,
resourceNames: resourceNames);
}
@@ -245,22 +248,7 @@ internal sealed partial class FileAgentSkillLoader
if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name))
{
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.");
return false;
}
// skillFilePath is e.g. "/skills/my-skill/SKILL.md".
// GetDirectoryName strips the filename → "/skills/my-skill".
// GetFileName then extracts the last segment → "my-skill".
// This gives us the skill's parent directory name to validate against the frontmatter name.
string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty;
if (!string.Equals(name, directoryName, StringComparison.Ordinal))
{
if (this._logger.IsEnabled(LogLevel.Error))
{
LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName));
}
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen.");
return false;
}
@@ -282,84 +270,34 @@ internal sealed partial class FileAgentSkillLoader
return true;
}
/// <summary>
/// Scans a skill directory for resource files matching the configured extensions.
/// </summary>
/// <remarks>
/// Recursively walks <paramref name="skillDirectoryFullPath"/> and collects files whose extension
/// matches <see cref="_allowedResourceExtensions"/>, excluding <c>SKILL.md</c> itself. Each candidate
/// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with
/// a warning.
/// </remarks>
private List<string> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
private bool ValidateResources(string skillDirectoryPath, List<string> resourceNames, string skillName)
{
string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar;
string normalizedSkillPath = Path.GetFullPath(skillDirectoryPath) + Path.DirectorySeparatorChar;
var resources = new List<string>();
#if NET
var enumerationOptions = new EnumerationOptions
foreach (string resourceName in resourceNames)
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
string fullPath = Path.GetFullPath(Path.Combine(skillDirectoryPath, resourceName));
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories))
#endif
{
string fileName = Path.GetFileName(filePath);
// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
if (!IsPathWithinDirectory(fullPath, normalizedSkillPath))
{
continue;
LogResourcePathTraversal(this._logger, skillName, resourceName);
return false;
}
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
if (!File.Exists(fullPath))
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
}
continue;
LogMissingResource(this._logger, skillName, resourceName);
return false;
}
// Normalize the enumerated path to guard against non-canonical forms
// (redundant separators, 8.3 short names, etc.) that would produce
// malformed relative resource names.
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment check
if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath))
if (HasSymlinkInPath(fullPath, normalizedSkillPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
LogResourceSymlinkEscape(this._logger, skillName, resourceName);
return false;
}
// Symlink check
if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize to forward slashes
string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length);
resources.Add(NormalizeResourcePath(relativePath));
}
return resources;
return true;
}
/// <summary>
@@ -398,6 +336,22 @@ internal sealed partial class FileAgentSkillLoader
return false;
}
private static List<string> ExtractResourcePaths(string content)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var paths = new List<string>();
foreach (Match m in s_resourceLinkRegex.Matches(content))
{
string path = NormalizeResourcePath(m.Groups[1].Value);
if (seen.Add(path))
{
paths.Add(path);
}
}
return paths;
}
/// <summary>
/// Normalizes a relative resource path by trimming a leading <c>./</c> prefix and replacing
/// backslashes with forward slashes so that <c>./refs/doc.md</c> and <c>refs/doc.md</c> are
@@ -418,43 +372,6 @@ internal sealed partial class FileAgentSkillLoader
return path;
}
/// <summary>
/// Replaces control characters in a file path with '?' to prevent log injection
/// via crafted filenames (e.g., filenames containing newlines on Linux).
/// </summary>
private static string SanitizePathForLog(string path)
{
char[]? chars = null;
for (int i = 0; i < path.Length; i++)
{
if (char.IsControl(path[i]))
{
chars ??= path.ToCharArray();
chars[i] = '?';
}
}
return chars is null ? path : new string(chars);
}
private static void ValidateExtensions(IEnumerable<string>? extensions)
{
if (extensions is null)
{
return;
}
foreach (string ext in extensions)
{
if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal))
{
#pragma warning disable CA2208 // Instantiate argument exceptions correctly
throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions));
#pragma warning restore CA2208 // Instantiate argument exceptions correctly
}
}
}
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
private static partial void LogSkillsDiscovered(ILogger logger, int count);
@@ -473,21 +390,18 @@ internal sealed partial class FileAgentSkillLoader
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}': skill name '{SkillName}' does not match parent directory name '{DirectoryName}'")]
private static partial void LogNameDirectoryMismatch(ILogger logger, string skillFilePath, string skillName, string directoryName);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': referenced resource '{ResourceName}' does not exist")]
private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")]
private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath);
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
}
@@ -88,7 +88,7 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FileAgentSkillsProvider>();
this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions);
this._loader = new FileAgentSkillLoader(this._logger);
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
@@ -18,15 +17,4 @@ public sealed class FileAgentSkillsProviderOptions
/// When <see langword="null"/>, a default template is used.
/// </summary>
public string? SkillsInstructionPrompt { get; set; }
/// <summary>
/// Gets or sets the file extensions recognized as discoverable skill resources.
/// Each value must start with a <c>'.'</c> character (for example, <c>.md</c>), and
/// extension comparisons are performed in a case-insensitive manner.
/// Files in the skill directory (and its subdirectories) whose extension matches
/// one of these values will be automatically discovered as resources.
/// When <see langword="null"/>, a default set of extensions is used
/// (<c>.md</c>, <c>.json</c>, <c>.yaml</c>, <c>.yml</c>, <c>.csv</c>, <c>.xml</c>, <c>.txt</c>).
/// </summary>
public IEnumerable<string>? AllowedResourceExtensions { get; set; }
}
@@ -1,9 +0,0 @@
# Integration Tests Azure Credentials
Adds a helper for loading Azure credentials in integration tests.
```xml
<PropertyGroup>
<InjectSharedIntegrationTestAzureCredentialsCode>true</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
```
@@ -1,28 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable IDE0005 // This is required in some projects and not in others.
using System;
#pragma warning restore IDE0005
using Azure.Identity;
namespace Shared.IntegrationTests;
/// <summary>
/// Provides credential instances for integration tests with
/// increased timeouts to avoid CI pipeline authentication failures.
/// </summary>
internal static class TestAzureCliCredentials
{
/// <summary>
/// The default timeout for Azure CLI credential operations.
/// Increased from the default (~13s) to accommodate CI pipeline latency.
/// </summary>
private static readonly TimeSpan s_processTimeout = TimeSpan.FromSeconds(60);
/// <summary>
/// Creates a new <see cref="AzureCliCredential"/> with an increased process timeout
/// suitable for CI environments.
/// </summary>
public static AzureCliCredential CreateAzureCliCredential() =>
new(new AzureCliCredentialOptions { ProcessTimeout = s_processTimeout });
}
@@ -15,15 +15,11 @@ public abstract class AgentTests<TAgentFixture>(Func<TAgentFixture> createAgentF
{
protected TAgentFixture Fixture { get; private set; } = default!;
public async ValueTask InitializeAsync()
public Task InitializeAsync()
{
this.Fixture = createAgentFixture();
await this.Fixture.InitializeAsync();
return this.Fixture.InitializeAsync();
}
public async ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
await this.Fixture.DisposeAsync();
}
public Task DisposeAsync() => this.Fixture.DisposeAsync();
}
@@ -0,0 +1 @@
bin/
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsTestProject>false</IsTestProject>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Defines an agent participating in a <see cref="IConversationTestCase"/>.
/// </summary>
public sealed class ConversationAgentDefinition
{
/// <summary>
/// Gets or sets the unique name identifying this agent within the test case.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets or sets the system instructions for the agent.
/// </summary>
public string Instructions { get; init; } = "You are a helpful assistant.";
/// <summary>
/// Gets or sets the optional list of tools available to the agent.
/// </summary>
public IList<AITool>? Tools { get; init; }
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Provides helpers for serializing and deserializing conversation contexts (lists of <see cref="ChatMessage"/>)
/// to and from JSON, enabling the initial context of a test case to be captured once and reused across runs.
/// </summary>
public static class ConversationContextSerializer
{
private static readonly JsonSerializerOptions s_serializerOptions = AgentAbstractionsJsonUtilities.DefaultOptions;
/// <summary>
/// Serializes a list of <see cref="ChatMessage"/> instances to a JSON string.
/// </summary>
/// <param name="messages">The messages to serialize.</param>
/// <returns>A JSON string representation of the messages.</returns>
public static string Serialize(IList<ChatMessage> messages) =>
JsonSerializer.Serialize(messages, s_serializerOptions);
/// <summary>
/// Deserializes a JSON string into a list of <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="json">The JSON string to deserialize.</param>
/// <returns>The deserialized list of messages.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when the JSON cannot be deserialized into a list of <see cref="ChatMessage"/> instances.
/// </exception>
public static IList<ChatMessage> Deserialize(string json)
{
var messages = JsonSerializer.Deserialize<List<ChatMessage>>(json, s_serializerOptions);
return messages ?? throw new InvalidOperationException("Failed to deserialize chat messages from the provided JSON.");
}
/// <summary>
/// Saves a list of <see cref="ChatMessage"/> instances to a JSON file.
/// </summary>
/// <param name="filePath">The path of the file to write.</param>
/// <param name="messages">The messages to save.</param>
public static void SaveToFile(string filePath, IList<ChatMessage> messages)
{
var json = Serialize(messages);
File.WriteAllText(filePath, json);
}
/// <summary>
/// Loads a list of <see cref="ChatMessage"/> instances from a JSON file.
/// </summary>
/// <param name="filePath">The path of the file to read.</param>
/// <returns>The deserialized list of messages.</returns>
/// <exception cref="FileNotFoundException">Thrown when <paramref name="filePath"/> does not exist.</exception>
public static IList<ChatMessage> LoadFromFile(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Conversation context file not found: '{filePath}'. " +
"Run the context creation step first to generate this file.", filePath);
}
var json = File.ReadAllText(filePath);
return Deserialize(json);
}
}
@@ -0,0 +1,214 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Orchestrates the execution of a <see cref="IConversationTestCase"/> against a given
/// <see cref="IConversationTestSystem"/>: restores the conversation context, optionally runs compaction,
/// executes each step, captures before/after metrics, and runs per-step validations.
/// </summary>
public sealed class ConversationHarness
{
private readonly IConversationTestSystem _system;
/// <summary>
/// Initializes a new instance of <see cref="ConversationHarness"/>.
/// </summary>
/// <param name="system">The system under test that provides agent creation and compaction.</param>
public ConversationHarness(IConversationTestSystem system)
{
if (system is null)
{
throw new ArgumentNullException(nameof(system));
}
this._system = system;
}
/// <summary>
/// Runs the supplied <paramref name="testCase"/> from its serialized initial context, executing
/// every <see cref="ConversationStep"/> in order and returning the combined metrics report.
/// </summary>
/// <param name="testCase">The test case to execute.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// A <see cref="ConversationMetricsReport"/> describing the before-and-after state of the
/// conversation context across all steps.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="testCase"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when a step references an agent name that is not present in <see cref="IConversationTestCase.AgentDefinitions"/>.
/// </exception>
public async Task<ConversationMetricsReport> RunAsync(
IConversationTestCase testCase,
CancellationToken cancellationToken = default)
{
if (testCase is null)
{
throw new ArgumentNullException(nameof(testCase));
}
// 1. Restore the initial context.
var initialMessages = testCase.GetInitialMessages();
// 2. Capture "before" metrics.
var beforeMetrics = MeasureMetrics(initialMessages);
// 3. Create the agents defined for this test case.
var agents = new Dictionary<string, AIAgent>(StringComparer.Ordinal);
foreach (var entry in testCase.AgentDefinitions)
{
agents[entry.Key] = await this._system.CreateAgentAsync(entry.Value, cancellationToken).ConfigureAwait(false);
}
// 4. Create sessions and restore the initial messages for each agent.
var sessions = new Dictionary<string, AgentSession>(StringComparer.Ordinal);
foreach (var entry in agents)
{
var session = await entry.Value.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
RestoreMessages(entry.Value, session, initialMessages);
sessions[entry.Key] = session;
}
// 5. Optionally compact the messages.
var compacted = await this._system.CompactAsync(initialMessages, cancellationToken).ConfigureAwait(false);
if (compacted is not null)
{
// Apply the compacted history to all agent sessions.
foreach (var entry in agents)
{
RestoreMessages(entry.Value, sessions[entry.Key], compacted);
}
}
// 6. Execute each step.
foreach (var step in testCase.Steps)
{
if (!agents.TryGetValue(step.AgentName, out var agent))
{
throw new InvalidOperationException(
$"Step references agent '{step.AgentName}' which is not defined in the test case. " +
$"Defined agents: {string.Join(", ", agents.Keys)}");
}
var session = sessions[step.AgentName];
AgentResponse response;
if (step.Input is not null)
{
response = await agent.RunAsync(step.Input, session, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
response = await agent.RunAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
}
// 7. Capture "after" metrics for this step and run the step's validation.
var currentMessages = GetCurrentMessages(agent, sessions[step.AgentName], initialMessages, compacted);
var afterMetrics = MeasureMetrics(currentMessages);
var metricsReport = new ConversationMetricsReport
{
Before = beforeMetrics,
After = afterMetrics,
};
step.Validate?.Invoke(response, metricsReport);
}
// 8. Capture the final "after" metrics from the first agent's session.
var firstAgent = agents.Values.First();
var firstSession = sessions[agents.Keys.First()];
var finalMessages = GetCurrentMessages(firstAgent, firstSession, initialMessages, compacted);
var finalAfterMetrics = MeasureMetrics(finalMessages);
return new ConversationMetricsReport
{
Before = beforeMetrics,
After = finalAfterMetrics,
};
}
/// <summary>
/// Drives a conversation with the agents defined in <paramref name="testCase"/> to produce the initial
/// context, then serializes that context to <paramref name="outputFilePath"/>.
/// </summary>
/// <remarks>
/// This method should be called once (outside of normal test execution) to generate the fixture
/// data that tests will subsequently restore via <see cref="IConversationTestCase.GetInitialMessages"/>.
/// </remarks>
/// <param name="testCase">The test case whose initial context should be created.</param>
/// <param name="outputFilePath">The file path to write the serialized context to.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public async Task SerializeInitialContextAsync(
IConversationTestCase testCase,
string outputFilePath,
CancellationToken cancellationToken = default)
{
if (testCase is null)
{
throw new ArgumentNullException(nameof(testCase));
}
if (string.IsNullOrEmpty(outputFilePath))
{
throw new ArgumentException("Output file path must not be null or empty.", nameof(outputFilePath));
}
// Create agents for context generation.
var agents = new Dictionary<string, AIAgent>(StringComparer.Ordinal);
foreach (var entry in testCase.AgentDefinitions)
{
agents[entry.Key] = await this._system.CreateAgentAsync(entry.Value, cancellationToken).ConfigureAwait(false);
}
var messages = await testCase.CreateInitialContextAsync(agents, cancellationToken).ConfigureAwait(false);
ConversationContextSerializer.SaveToFile(outputFilePath, messages);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static ConversationMetrics MeasureMetrics(IList<ChatMessage> messages)
{
var serialized = ConversationContextSerializer.Serialize(messages);
return new ConversationMetrics
{
MessageCount = messages.Count,
SerializedSizeBytes = System.Text.Encoding.UTF8.GetByteCount(serialized),
};
}
private static void RestoreMessages(AIAgent agent, AgentSession session, IList<ChatMessage> messages)
{
// InMemoryChatHistoryProvider is the standard history provider for ChatClientAgent.
// When found, load the messages directly into the provider's state for this session.
if (agent.GetService<ChatHistoryProvider>() is InMemoryChatHistoryProvider memProvider)
{
memProvider.SetMessages(session, messages.ToList());
}
}
private static IList<ChatMessage> GetCurrentMessages(
AIAgent agent,
AgentSession session,
IList<ChatMessage> fallbackInitial,
IList<ChatMessage>? compacted)
{
if (agent.GetService<ChatHistoryProvider>() is InMemoryChatHistoryProvider memProvider)
{
return memProvider.GetMessages(session);
}
// Fall back to the compacted (or original) initial messages when the provider is unavailable.
return compacted ?? fallbackInitial;
}
}
@@ -0,0 +1,138 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using Xunit.Abstractions;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Abstract xunit base class for agent conversation integration tests.
/// </summary>
/// <remarks>
/// <para>
/// Subclasses must implement <see cref="CreateTestSystem"/> and <see cref="GetTestCases"/> to provide
/// the AI backend and the set of test cases to run. Each subclass will automatically inherit the
/// <see cref="RunAllTestCasesAsync"/> test method, which runs every case returned by
/// <see cref="GetTestCases"/> through the <see cref="ConversationHarness"/>.
/// </para>
/// <para>
/// To generate (and serialize) the initial context for a test case, the same subclass inherits
/// <see cref="SerializeAllInitialContextsAsync"/>, which should be run once outside of normal CI.
/// </para>
/// </remarks>
/// <typeparam name="TSystem">
/// The concrete <see cref="IConversationTestSystem"/> implementation that provides agent creation
/// and compaction for the system under test.
/// </typeparam>
public abstract class ConversationHarnessTests<TSystem>
where TSystem : IConversationTestSystem
{
private readonly ITestOutputHelper? _output;
/// <summary>
/// Initializes a new instance of <see cref="ConversationHarnessTests{TSystem}"/>.
/// </summary>
protected ConversationHarnessTests()
{
}
/// <summary>
/// Initializes a new instance of <see cref="ConversationHarnessTests{TSystem}"/> with xunit test output.
/// </summary>
/// <param name="output">The xunit test output helper used to log metrics and step results.</param>
protected ConversationHarnessTests(ITestOutputHelper output)
{
this._output = output;
}
/// <summary>
/// Creates the <see cref="IConversationTestSystem"/> to use for agent creation and compaction.
/// </summary>
protected abstract TSystem CreateTestSystem();
/// <summary>
/// Returns the set of <see cref="IConversationTestCase"/> instances to exercise.
/// </summary>
protected abstract IEnumerable<IConversationTestCase> GetTestCases();
/// <summary>
/// Runs all test cases returned by <see cref="GetTestCases"/> and logs the metrics report for each.
/// </summary>
[Fact]
public virtual async Task RunAllTestCasesAsync()
{
var system = this.CreateTestSystem();
var harness = new ConversationHarness(system);
foreach (var testCase in this.GetTestCases())
{
this.Log($"[{testCase.Name}] Running...");
var stopwatch = Stopwatch.StartNew();
var report = await harness.RunAsync(testCase);
stopwatch.Stop();
this.Log($"[{testCase.Name}] Completed in {stopwatch.ElapsedMilliseconds}ms. Metrics: {report}");
}
}
/// <summary>
/// Generates and serializes the initial context for each test case returned by <see cref="GetTestCases"/>.
/// </summary>
/// <remarks>
/// This test is skipped during normal test runs because generating contexts requires live AI calls and
/// can be expensive. Run it explicitly (e.g., with <c>dotnet test --filter "FullyQualifiedName~Serialize"</c>)
/// to regenerate the fixture files. After running, commit the generated files alongside the test code.
/// </remarks>
[Fact(Skip = "Run explicitly to regenerate initial context fixture files.")]
public virtual async Task SerializeAllInitialContextsAsync()
{
var system = this.CreateTestSystem();
var harness = new ConversationHarness(system);
foreach (var testCase in this.GetTestCases())
{
var outputPath = GetDefaultContextFilePath(testCase);
this.Log($"[{testCase.Name}] Serializing initial context to '{outputPath}'...");
await harness.SerializeInitialContextAsync(testCase, outputPath);
this.Log($"[{testCase.Name}] Context serialized successfully.");
}
}
// -------------------------------------------------------------------------
// Protected helpers for subclasses
// -------------------------------------------------------------------------
/// <summary>
/// Computes the default file path used by <see cref="SerializeAllInitialContextsAsync"/> when
/// writing the initial context for <paramref name="testCase"/>.
/// </summary>
/// <remarks>
/// Override this method to change the output location. The default path is
/// <c>{TestCase.Name}.context.json</c> relative to the current working directory.
/// </remarks>
/// <param name="testCase">The test case whose default output path is required.</param>
/// <returns>The absolute or relative file path to write the serialized context to.</returns>
protected virtual string GetDefaultContextFilePath(IConversationTestCase testCase) =>
$"{testCase.Name}.context.json";
/// <summary>
/// Writes a message to the xunit test output, if available, otherwise to the console.
/// </summary>
protected void Log(string message)
{
if (this._output is not null)
{
this._output.WriteLine(message);
}
else
{
Console.WriteLine(message);
}
}
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Captures the size characteristics of a conversation context at a specific point in time.
/// </summary>
public sealed class ConversationMetrics
{
/// <summary>
/// Gets the number of messages in the conversation context.
/// </summary>
public required int MessageCount { get; init; }
/// <summary>
/// Gets the approximate serialized size of the conversation context in bytes.
/// This serves as a proxy for context window consumption.
/// </summary>
public required long SerializedSizeBytes { get; init; }
/// <inheritdoc />
public override string ToString() =>
$"Messages={MessageCount}, Size={SerializedSizeBytes}B";
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Captures the before-and-after <see cref="ConversationMetrics"/> for a single test case run,
/// enabling comparison and reporting of context size changes.
/// </summary>
public sealed class ConversationMetricsReport
{
/// <summary>
/// Gets the metrics captured before the agent steps were executed.
/// </summary>
public required ConversationMetrics Before { get; init; }
/// <summary>
/// Gets the metrics captured after the agent steps were executed.
/// </summary>
public required ConversationMetrics After { get; init; }
/// <summary>
/// Gets the change in message count between <see cref="Before"/> and <see cref="After"/>.
/// A positive value means messages were added; a negative value means compaction removed messages.
/// </summary>
public int MessageCountDelta => After.MessageCount - Before.MessageCount;
/// <summary>
/// Gets the change in serialized size in bytes between <see cref="Before"/> and <see cref="After"/>.
/// </summary>
public long SizeDeltaBytes => After.SerializedSizeBytes - Before.SerializedSizeBytes;
/// <inheritdoc />
public override string ToString() =>
$"Before=[{Before}] After=[{After}] Delta=[Messages={MessageCountDelta:+#;-#;0}, Size={SizeDeltaBytes:+#;-#;0}B]";
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Represents a single step within a <see cref="IConversationTestCase"/>, combining the agent to invoke,
/// an optional input message, and an optional validation delegate.
/// </summary>
public sealed class ConversationStep
{
/// <summary>
/// Gets or sets the name of the agent to invoke for this step.
/// Must match a key in <see cref="IConversationTestCase.AgentDefinitions"/>.
/// </summary>
public required string AgentName { get; init; }
/// <summary>
/// Gets or sets the optional input message to send to the agent.
/// When <see langword="null"/>, the agent is invoked with no new user input (useful for
/// eliciting a response from the existing conversation context).
/// </summary>
public ChatMessage? Input { get; init; }
/// <summary>
/// Gets or sets an optional delegate that validates the agent response and metrics for this step.
/// When <see langword="null"/>, no validation is performed.
/// </summary>
public Action<AgentResponse, ConversationMetricsReport>? Validate { get; init; }
}
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Defines a single agent conversation test case.
/// </summary>
/// <remarks>
/// Each test case describes the initial conversation context (as a list of <see cref="ChatMessage"/> instances),
/// the agents that participate in the conversation, the steps to execute, and the expected outcomes.
/// The initial context can either be loaded from a previously serialized file or generated on-demand
/// via <see cref="CreateInitialContextAsync"/>.
/// </remarks>
public interface IConversationTestCase
{
/// <summary>
/// Gets the human-readable name that uniquely identifies this test case.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the agents involved in this test case, keyed by their names.
/// Each entry is a <see cref="ConversationAgentDefinition"/> that describes how to create the agent.
/// </summary>
IReadOnlyDictionary<string, ConversationAgentDefinition> AgentDefinitions { get; }
/// <summary>
/// Returns the initial list of <see cref="ChatMessage"/> instances to restore into the conversation
/// context before any steps are executed.
/// </summary>
/// <returns>
/// The initial chat messages. These are typically loaded from a previously serialized JSON file
/// produced by <see cref="CreateInitialContextAsync"/>.
/// </returns>
IList<ChatMessage> GetInitialMessages();
/// <summary>
/// Gets the ordered list of steps to execute against the restored conversation context.
/// </summary>
IReadOnlyList<ConversationStep> Steps { get; }
/// <summary>
/// Creates the initial conversation context by actually driving a conversation with the provided agents,
/// then returns the resulting list of messages.
/// </summary>
/// <remarks>
/// This method is intended to be called once (e.g., during a setup phase) to produce the serialized
/// context that subsequent test runs will deserialize. Implementations should build up a long or
/// complex conversation that is representative of the long-running operation scenario being validated.
/// </remarks>
/// <param name="agents">
/// The agents to use when building the initial context, keyed by their names as defined in
/// <see cref="AgentDefinitions"/>.
/// </param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// The ordered list of <see cref="ChatMessage"/> instances that form the initial context.
/// </returns>
Task<IList<ChatMessage>> CreateInitialContextAsync(
IReadOnlyDictionary<string, AIAgent> agents,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Abstracts the system-specific concerns of an agent conversation test run: how agents are created
/// and how context compaction is performed.
/// </summary>
/// <remarks>
/// Implement this interface to adapt the <see cref="ConversationHarness"/> to a particular AI backend
/// (e.g., OpenAI Chat Completion, Azure AI, OpenAI Responses API). Each implementation controls:
/// <list type="bullet">
/// <item><description>
/// How <see cref="ConversationAgentDefinition"/> instances are turned into live <see cref="AIAgent"/> objects.
/// </description></item>
/// <item><description>
/// How context compaction is applied to a list of messages. Compaction is optional; returning
/// <see langword="null"/> means no compaction is performed.
/// </description></item>
/// </list>
/// </remarks>
public interface IConversationTestSystem
{
/// <summary>
/// Creates a live <see cref="AIAgent"/> from the supplied <paramref name="definition"/>.
/// </summary>
/// <param name="definition">The definition describing the agent to create.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A fully-initialised <see cref="AIAgent"/> ready to participate in the conversation.</returns>
Task<AIAgent> CreateAgentAsync(
ConversationAgentDefinition definition,
CancellationToken cancellationToken = default);
/// <summary>
/// Optionally compacts (reduces) the supplied <paramref name="messages"/>.
/// </summary>
/// <param name="messages">The current list of messages to compact.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// The compacted list of messages, or <see langword="null"/> if no compaction was performed.
/// When <see langword="null"/> is returned the original <paramref name="messages"/> list is used unchanged.
/// </returns>
Task<IList<ChatMessage>?> CompactAsync(
IList<ChatMessage> messages,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,163 @@
# AgentConversation Integration Test Harness
The `AgentConversation.IntegrationTests` project provides a **reusable test harness** for validating conversation dynamics in long-running, multi-agent scenarios that involve tool use. Instead of rebuilding a large conversation context in every test run, the harness allows you to:
1. **Capture** a representative conversation context once (by driving a real conversation with your AI agents).
2. **Serialize** that context to a JSON file and commit it alongside your tests.
3. **Restore** the saved context in each test run.
4. **Solicit** one or more agent responses from the restored context.
5. **Validate** each response and compare before/after metrics.
---
## Key Abstractions
| Type | Role |
|------|------|
| `IConversationTestCase` | Defines a test case: agents, initial messages, ordered steps, and a method to automate context creation. |
| `IConversationTestSystem` | System-specific plugin: how to **create agents** and how to apply **context compaction** (both vary per AI backend). |
| `ConversationAgentDefinition` | Describes a participating agent — name, instructions, and optional tools. |
| `ConversationStep` | One step in the conversation: which agent to invoke, an optional input message, and an optional validation delegate. |
| `ConversationMetrics` | A snapshot of conversation context size — message count and serialized byte size. |
| `ConversationMetricsReport` | A before/after `ConversationMetrics` pair with delta helpers for reporting. |
| `ConversationContextSerializer` | Serializes and deserializes `IList<ChatMessage>` to/from JSON strings or files. |
| `ConversationHarness` | The core runner that ties everything together. |
| `ConversationHarnessTests<TSystem>` | Abstract xunit base class that subclasses inherit to get the `RunAllTestCasesAsync` test. |
---
## How It Works
### 1. Implement `IConversationTestSystem`
Provide an implementation that knows how to create agents for your target AI backend and optionally compact messages:
```csharp
public sealed class OpenAIConversationTestSystem : IConversationTestSystem
{
public Task<AIAgent> CreateAgentAsync(ConversationAgentDefinition definition, CancellationToken ct = default)
{
var chatClient = new OpenAIClient(apiKey)
.GetChatClient("gpt-4o")
.AsIChatClient();
AIAgent agent = new ChatClientAgent(chatClient, options: new()
{
Name = definition.Name,
ChatOptions = new() { Instructions = definition.Instructions, Tools = definition.Tools }
});
return Task.FromResult(agent);
}
public Task<IList<ChatMessage>?> CompactAsync(IList<ChatMessage> messages, CancellationToken ct = default)
{
// Return null for no compaction, or apply an IChatReducer here.
return Task.FromResult<IList<ChatMessage>?>(null);
}
}
```
### 2. Implement `IConversationTestCase`
Define the agents involved, the initial context to restore, and the steps to execute:
```csharp
public sealed class MyConversationTestCase : IConversationTestCase
{
public string Name => "MyConversation";
public IReadOnlyDictionary<string, ConversationAgentDefinition> AgentDefinitions { get; } =
new Dictionary<string, ConversationAgentDefinition>
{
["Assistant"] = new() { Name = "Assistant", Instructions = "You are a helpful assistant." }
};
// Load the saved context from a JSON fixture file.
public IList<ChatMessage> GetInitialMessages() =>
ConversationContextSerializer.LoadFromFile("fixtures/my-conversation.context.json");
public IReadOnlyList<ConversationStep> Steps { get; } =
[
new ConversationStep
{
AgentName = "Assistant",
Input = new ChatMessage(ChatRole.User, "Summarize our conversation so far."),
Validate = (response, metrics) =>
{
Assert.NotEmpty(response.Text);
Assert.True(metrics.After.MessageCount > metrics.Before.MessageCount);
}
}
];
// Called once to generate the fixture file — not during normal CI runs.
public async Task<IList<ChatMessage>> CreateInitialContextAsync(
IReadOnlyDictionary<string, AIAgent> agents, CancellationToken ct = default)
{
var agent = agents["Assistant"];
var session = await agent.CreateSessionAsync(ct);
// Drive a rich, representative conversation to build up the context.
await agent.RunAsync(new ChatMessage(ChatRole.User, "Tell me about the weather."), session, cancellationToken: ct);
await agent.RunAsync(new ChatMessage(ChatRole.User, "What about tomorrow?"), session, cancellationToken: ct);
// ... more turns ...
var provider = agent.GetService<InMemoryChatHistoryProvider>()!;
return provider.GetMessages(session);
}
}
```
### 3. Derive from `ConversationHarnessTests<TSystem>`
Wire the system and test cases into a concrete test class:
```csharp
public class MyConversationTests(ITestOutputHelper output)
: ConversationHarnessTests<OpenAIConversationTestSystem>(output)
{
protected override OpenAIConversationTestSystem CreateTestSystem() => new();
protected override IEnumerable<IConversationTestCase> GetTestCases() =>
[
new MyConversationTestCase(),
];
}
```
The inherited `RunAllTestCasesAsync` test method will automatically run all cases and log the before/after metrics to the xunit test output.
---
## Generating Initial Context Fixtures
The context fixture files need to be generated once and committed to the repository. Run the inherited `SerializeAllInitialContextsAsync` test to produce them:
```bash
dotnet test --filter "FullyQualifiedName~SerializeAllInitialContexts"
```
This test is **skipped during normal CI runs** to avoid expensive AI calls. After generating the files, commit them alongside your test code so that all subsequent runs can restore the context without calling the AI service again.
---
## Metrics Reporting
After each test case runs, a `ConversationMetricsReport` is logged. It captures:
- **`Before`** — message count and serialized byte size of the initial context.
- **`After`** — message count and byte size after all steps have executed.
- **`MessageCountDelta`** / **`SizeDeltaBytes`** — the change between before and after.
Example output:
```
[MyConversation] Before=[Messages=12, Size=4096B] After=[Messages=14, Size=4712B] Delta=[Messages=+2, Size=+616B]
```
---
## Example
See [`Microsoft.Agents.AI.Abstractions.IntegrationTests`](../Microsoft.Agents.AI.Abstractions.IntegrationTests) for a self-contained working example that uses an in-memory mock `IChatClient` so it runs without live AI credentials.
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
</PropertyGroup>
@@ -1,13 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
public abstract class SkipAllChatClientRunStreaming(Func<AnthropicChatCompletionFixture> func) : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(func)
{
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync()
=> base.RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync();
public class AnthropicBetaChatCompletionChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
=> base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
}
public class AnthropicChatCompletionChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: true, useBeta: true));
public class AnthropicChatCompletionChatClientAgentReasoningRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
public class AnthropicBetaChatCompletionChatClientAgentRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: false, useBeta: true));
public class AnthropicChatCompletionChatClientAgentRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: false, useBeta: false));
public class AnthropicChatCompletionChatClientAgentReasoningRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: true, useBeta: false));
@@ -1,17 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
public abstract class SkipAllChatClientAgentRun(Func<AnthropicChatCompletionFixture> func) : ChatClientAgentRunTests<AnthropicChatCompletionFixture>(func)
{
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync()
=> base.RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync();
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
=> base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
}
public class AnthropicBetaChatCompletionChatClientAgentRunTests()
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: false, useBeta: true));
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunTests()
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: true, useBeta: true));
public class AnthropicChatCompletionChatClientAgentRunTests()
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: false, useBeta: false));
public class AnthropicChatCompletionChatClientAgentReasoningRunTests()
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: true, useBeta: false));
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@@ -103,15 +102,9 @@ public class AnthropicChatCompletionFixture : IChatClientAgentFixture
// Chat Completion does not require/support deleting sessions, so this is a no-op.
Task.CompletedTask;
public async ValueTask InitializeAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
public async Task InitializeAsync() =>
this._agent = await this.CreateChatClientAgentAsync();
}
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
return default;
}
public Task DisposeAsync() =>
Task.CompletedTask;
}
@@ -1,17 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
public abstract class SkipAllRunStreaming(Func<AnthropicChatCompletionFixture> func) : RunStreamingTests<AnthropicChatCompletionFixture>(func)
{
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithChatMessageReturnsExpectedResultAsync() => base.RunWithChatMessageReturnsExpectedResultAsync();
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() => base.RunWithNoMessageDoesNotFailAsync();
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithChatMessagesReturnsExpectedResultAsync() => base.RunWithChatMessagesReturnsExpectedResultAsync();
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithStringReturnsExpectedResultAsync() => base.RunWithStringReturnsExpectedResultAsync();
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task SessionMaintainsHistoryAsync() => base.SessionMaintainsHistoryAsync();
}
public class AnthropicBetaChatCompletionRunStreamingTests()
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
: SkipAllRunStreaming(() => new(useReasoningChatModel: false, useBeta: true));
public class AnthropicBetaChatCompletionReasoningRunStreamingTests()
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
: SkipAllRunStreaming(() => new(useReasoningChatModel: true, useBeta: true));
public class AnthropicChatCompletionRunStreamingTests()
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
: SkipAllRunStreaming(() => new(useReasoningChatModel: false, useBeta: false));
public class AnthropicChatCompletionReasoningRunStreamingTests()
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
: SkipAllRunStreaming(() => new(useReasoningChatModel: true, useBeta: false));
@@ -1,17 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
public abstract class SkipAllRun(Func<AnthropicChatCompletionFixture> func) : RunTests<AnthropicChatCompletionFixture>(func)
{
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithChatMessageReturnsExpectedResultAsync() => base.RunWithChatMessageReturnsExpectedResultAsync();
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() => base.RunWithNoMessageDoesNotFailAsync();
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithChatMessagesReturnsExpectedResultAsync() => base.RunWithChatMessagesReturnsExpectedResultAsync();
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task RunWithStringReturnsExpectedResultAsync() => base.RunWithStringReturnsExpectedResultAsync();
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
public override Task SessionMaintainsHistoryAsync() => base.SessionMaintainsHistoryAsync();
}
public class AnthropicBetaChatCompletionRunTests()
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
: SkipAllRun(() => new(useReasoningChatModel: false, useBeta: true));
public class AnthropicBetaChatCompletionReasoningRunTests()
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
: SkipAllRun(() => new(useReasoningChatModel: true, useBeta: true));
public class AnthropicChatCompletionRunTests()
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
: SkipAllRun(() => new(useReasoningChatModel: false, useBeta: false));
public class AnthropicChatCompletionReasoningRunTests()
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
: SkipAllRun(() => new(useReasoningChatModel: true, useBeta: false));
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
@@ -22,11 +22,9 @@ public sealed class AnthropicSkillsIntegrationTests
// All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup.
private const string SkipReason = "Integrations tests for local execution only";
[Fact]
[Fact(Skip = SkipReason)]
public async Task CreateAgentWithPptxSkillAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
// Arrange
AnthropicClient anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
string model = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
@@ -53,11 +51,9 @@ public sealed class AnthropicSkillsIntegrationTests
Assert.NotEmpty(response.Text);
}
[Fact]
[Fact(Skip = SkipReason)]
public async Task ListAnthropicManagedSkillsAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
// Arrange
AnthropicClient anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
@@ -9,10 +9,10 @@ namespace AzureAI.IntegrationTests;
public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStreamingTests<AIProjectClientFixture>(() => new())
{
[Fact(Skip = "No messages is not supported")]
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
return Task.CompletedTask;
}
}
@@ -24,9 +24,9 @@ public class AIProjectClientAgentRunStreamingConversationTests() : RunTests<AIPr
return new ChatClientAgentRunOptions(new() { ConversationId = conversationId });
};
[Fact(Skip = "No messages is not supported")]
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
return Task.CompletedTask;
}
}
@@ -9,10 +9,10 @@ namespace AzureAI.IntegrationTests;
public class AIProjectClientAgentRunPreviousResponseTests() : RunTests<AIProjectClientFixture>(() => new())
{
[Fact(Skip = "No messages is not supported")]
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
return Task.CompletedTask;
}
}
@@ -24,9 +24,9 @@ public class AIProjectClientAgentRunConversationTests() : RunTests<AIProjectClie
return new ChatClientAgentRunOptions(new() { ConversationId = conversationId });
};
[Fact(Skip = "No messages is not supported")]
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
return Task.CompletedTask;
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
@@ -65,23 +66,17 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu
Assert.Equal("Paris", response.Result.Name);
}
public override Task RunWithGenericTypeReturnsExpectedResultAsync()
{
Assert.Skip(NotSupported);
return base.RunWithGenericTypeReturnsExpectedResultAsync();
}
[Fact(Skip = NotSupported)]
public override Task RunWithGenericTypeReturnsExpectedResultAsync() =>
base.RunWithGenericTypeReturnsExpectedResultAsync();
public override Task RunWithResponseFormatReturnsExpectedResultAsync()
{
Assert.Skip(NotSupported);
return base.RunWithResponseFormatReturnsExpectedResultAsync();
}
[Fact(Skip = NotSupported)]
public override Task RunWithResponseFormatReturnsExpectedResultAsync() =>
base.RunWithResponseFormatReturnsExpectedResultAsync();
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
{
Assert.Skip(NotSupported);
return base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
}
[Fact(Skip = NotSupported)]
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() =>
base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
}
/// <summary>
@@ -89,7 +84,7 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu
/// </summary>
public class AIProjectClientStructuredOutputFixture<T> : AIProjectClientFixture
{
public override async ValueTask InitializeAsync()
public override Task InitializeAsync()
{
var agentOptions = new ChatClientAgentOptions
{
@@ -99,6 +94,6 @@ public class AIProjectClientStructuredOutputFixture<T> : AIProjectClientFixture
},
};
await this.InitializeAsync(agentOptions);
return this.InitializeAsync(agentOptions);
}
}
@@ -7,9 +7,9 @@ namespace AzureAI.IntegrationTests;
public class AIProjectClientChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AIProjectClientFixture>(() => new())
{
[Fact(Skip = "No messages is not supported")]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
return Task.CompletedTask;
}
}
@@ -7,9 +7,9 @@ namespace AzureAI.IntegrationTests;
public class AIProjectClientChatClientAgentRunTests() : ChatClientAgentRunTests<AIProjectClientFixture>(() => new())
{
[Fact(Skip = "No messages is not supported")]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
return Task.CompletedTask;
}
}
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Files;
@@ -16,7 +17,7 @@ namespace AzureAI.IntegrationTests;
public class AIProjectClientCreateTests
{
private readonly AIProjectClient _client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
private readonly AIProjectClient _client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), new AzureCliCredential());
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
@@ -8,6 +8,7 @@ using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
@@ -155,27 +156,25 @@ public class AIProjectClientFixture : IChatClientAgentFixture
}
}
public ValueTask DisposeAsync()
public Task DisposeAsync()
{
GC.SuppressFinalize(this);
if (this._client is not null && this._agent is not null)
{
return new ValueTask(this._client.Agents.DeleteAgentAsync(this._agent.Name));
return this._client.Agents.DeleteAgentAsync(this._agent.Name);
}
return default;
return Task.CompletedTask;
}
public virtual async ValueTask InitializeAsync()
public virtual async Task InitializeAsync()
{
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), new AzureCliCredential());
this._agent = await this.CreateChatClientAgentAsync();
}
public async Task InitializeAsync(ChatClientAgentOptions options)
{
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), new AzureCliCredential());
this._agent = await this.CreateChatClientAgentAsync(options);
}
}
@@ -1,9 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
<ItemGroup>
@@ -1,9 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
<ItemGroup>
@@ -6,6 +6,7 @@ using System.IO;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
@@ -14,7 +15,7 @@ namespace AzureAIAgentsPersistent.IntegrationTests;
public class AzureAIAgentsPersistentCreateTests
{
private readonly PersistentAgentsClient _persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), TestAzureCliCredentials.CreateAzureCliCredential());
private readonly PersistentAgentsClient _persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), new AzureCliCredential());
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
@@ -131,15 +132,10 @@ public class AzureAIAgentsPersistentCreateTests
}
}
[Fact]
public Task CreateAgent_CreatesAgentWithCodeInterpreter_ChatClientAgentOptionsAsync()
=> this.CreateAgent_CreatesAgentWithCodeInterpreterAsync("CreateWithChatClientAgentOptionsAsync");
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public Task CreateAgent_CreatesAgentWithCodeInterpreter_FoundryOptionsAsync()
=> this.CreateAgent_CreatesAgentWithCodeInterpreterAsync("CreateWithFoundryOptionsAsync");
private async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithFoundryOptionsAsync")]
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
{
// Arrange.
const string AgentInstructions = """
@@ -1,12 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Azure;
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
@@ -84,21 +84,19 @@ public class AzureAIAgentsPersistentFixture : IChatClientAgentFixture
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
public Task DisposeAsync()
{
GC.SuppressFinalize(this);
if (this._persistentAgentsClient is not null && this._agent is not null)
{
return new ValueTask(this._persistentAgentsClient.Administration.DeleteAgentAsync(this._agent.Id));
return this._persistentAgentsClient.Administration.DeleteAgentAsync(this._agent.Id);
}
return default;
return Task.CompletedTask;
}
public async ValueTask InitializeAsync()
public async Task InitializeAsync()
{
this._persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), TestAzureCliCredentials.CreateAzureCliCredential());
this._persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), new AzureCliCredential());
this._agent = await this.CreateChatClientAgentAsync();
}
}
@@ -9,21 +9,15 @@ public class AzureAIAgentsPersistentStructuredOutputRunTests() : StructuredOutpu
{
private const string SkipReason = "Fails intermittently on the build agent/CI";
public override Task RunWithResponseFormatReturnsExpectedResultAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
return base.RunWithResponseFormatReturnsExpectedResultAsync();
}
[Fact(Skip = SkipReason)]
public override Task RunWithResponseFormatReturnsExpectedResultAsync() =>
base.RunWithResponseFormatReturnsExpectedResultAsync();
public override Task RunWithGenericTypeReturnsExpectedResultAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
return base.RunWithGenericTypeReturnsExpectedResultAsync();
}
[Fact(Skip = SkipReason)]
public override Task RunWithGenericTypeReturnsExpectedResultAsync() =>
base.RunWithGenericTypeReturnsExpectedResultAsync();
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
return base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
}
[Fact(Skip = SkipReason)]
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() =>
base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
}
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
@@ -28,24 +28,16 @@ public class CopilotStudioFixture : IAgentFixture
// Chat Completion does not require/support deleting threads, so this is a no-op.
Task.CompletedTask;
public ValueTask InitializeAsync()
public Task InitializeAsync()
{
const string CopilotStudioHttpClientName = nameof(CopilotStudioAgent);
CopilotStudioConnectionSettings? settings = null;
try
var settings = new CopilotStudioConnectionSettings(
TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioTenantId),
TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioAgentAppId))
{
settings = new CopilotStudioConnectionSettings(
TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioTenantId),
TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioAgentAppId))
{
DirectConnectUrl = TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioDirectConnectUrl),
};
}
catch (InvalidOperationException ex)
{
Assert.Skip("CopilotStudio configuration could not be loaded. Error:" + ex.Message);
}
DirectConnectUrl = TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioDirectConnectUrl),
};
ServiceCollection services = new();
@@ -64,12 +56,8 @@ public class CopilotStudioFixture : IAgentFixture
this.Agent = new CopilotStudioAgent(client);
return default;
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
return default;
}
public Task DisposeAsync() => Task.CompletedTask;
}
@@ -10,33 +10,23 @@ public class CopilotStudioRunStreamingTests() : RunStreamingTests<CopilotStudioF
// Set to null to run the tests.
private const string ManualVerification = "For manual verification";
public override Task SessionMaintainsHistoryAsync()
{
Assert.Skip("Copilot Studio does not support session history retrieval, so this test is not applicable.");
return base.SessionMaintainsHistoryAsync();
}
[Fact(Skip = "Copilot Studio does not support session history retrieval, so this test is not applicable.")]
public override Task SessionMaintainsHistoryAsync() =>
Task.CompletedTask;
public override Task RunWithChatMessageReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithChatMessageReturnsExpectedResultAsync();
}
[Fact(Skip = ManualVerification)]
public override Task RunWithChatMessageReturnsExpectedResultAsync() =>
base.RunWithChatMessageReturnsExpectedResultAsync();
public override Task RunWithChatMessagesReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithChatMessagesReturnsExpectedResultAsync();
}
[Fact(Skip = ManualVerification)]
public override Task RunWithChatMessagesReturnsExpectedResultAsync() =>
base.RunWithChatMessagesReturnsExpectedResultAsync();
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithNoMessageDoesNotFailAsync();
}
[Fact(Skip = ManualVerification)]
public override Task RunWithNoMessageDoesNotFailAsync() =>
base.RunWithNoMessageDoesNotFailAsync();
public override Task RunWithStringReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithStringReturnsExpectedResultAsync();
}
[Fact(Skip = ManualVerification)]
public override Task RunWithStringReturnsExpectedResultAsync() =>
base.RunWithStringReturnsExpectedResultAsync();
}
@@ -10,33 +10,23 @@ public class CopilotStudioRunTests() : RunTests<CopilotStudioFixture>(() => new(
// Set to null to run the tests.
private const string ManualVerification = "For manual verification";
public override Task SessionMaintainsHistoryAsync()
{
Assert.Skip("Copilot Studio does not support session history retrieval, so this test is not applicable.");
return base.SessionMaintainsHistoryAsync();
}
[Fact(Skip = "Copilot Studio does not support session history retrieval, so this test is not applicable.")]
public override Task SessionMaintainsHistoryAsync() =>
Task.CompletedTask;
public override Task RunWithChatMessageReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithChatMessageReturnsExpectedResultAsync();
}
[Fact(Skip = ManualVerification)]
public override Task RunWithChatMessageReturnsExpectedResultAsync() => base.RunWithChatMessageReturnsExpectedResultAsync();
public override Task RunWithChatMessagesReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithChatMessagesReturnsExpectedResultAsync();
}
[Fact(Skip = ManualVerification)]
public override Task RunWithChatMessagesReturnsExpectedResultAsync() =>
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithNoMessageDoesNotFailAsync();
}
base.RunWithChatMessagesReturnsExpectedResultAsync();
public override Task RunWithStringReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithStringReturnsExpectedResultAsync();
}
[Fact(Skip = ManualVerification)]
public override Task RunWithNoMessageDoesNotFailAsync() =>
base.RunWithNoMessageDoesNotFailAsync();
[Fact(Skip = ManualVerification)]
public override Task RunWithStringReturnsExpectedResultAsync() =>
base.RunWithStringReturnsExpectedResultAsync();
}
+5 -8
View File
@@ -6,25 +6,22 @@
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<IsAotCompatible>false</IsAotCompatible>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0;net472</TargetFrameworks>
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
<NoWarn>$(NoWarn);Moq1410;xUnit1051;MAAI001</NoWarn>
<NoWarn>$(NoWarn);Moq1410;xUnit2023;MAAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xRetry.v3" />
<PackageReference Include="xunit.v3.mtp-v2" />
<PackageReference Include="xRetry" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="xRetry.v3" />
<Using Include="xRetry" />
<Using Include="Xunit" />
</ItemGroup>
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using AgentConversation.IntegrationTests;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Abstractions.IntegrationTests;
/// <summary>
/// Example integration tests that exercise the <see cref="ConversationHarness"/> using an
/// in-memory <see cref="InMemoryConversationTestSystem"/> that does not require live AI credentials.
/// </summary>
/// <remarks>
/// <para>
/// This class derives from <see cref="ConversationHarnessTests{TSystem}"/> and provides the system
/// and test cases. The <see cref="ConversationHarnessTests{TSystem}.RunAllTestCasesAsync"/> test
/// method is inherited automatically and will run all cases returned by <see cref="GetTestCases"/>.
/// </para>
/// <para>
/// To adapt these tests to a real AI backend, replace <see cref="InMemoryConversationTestSystem"/>
/// with an implementation that constructs agents backed by your AI service.
/// </para>
/// </remarks>
public class AgentConversationHarnessTests(ITestOutputHelper output)
: ConversationHarnessTests<InMemoryConversationTestSystem>(output)
{
/// <inheritdoc />
protected override InMemoryConversationTestSystem CreateTestSystem() =>
new InMemoryConversationTestSystem();
/// <inheritdoc />
protected override IEnumerable<IConversationTestCase> GetTestCases() =>
[
new MenuConversationTestCase(),
];
}

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