mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a7a4da87 | ||
|
|
43d226f8ca | ||
|
|
1a8a58f753 | ||
|
|
c513694b2f | ||
|
|
f42863e354 | ||
|
|
06f55c0494 | ||
|
|
55ddd841b7 | ||
|
|
4a043c6c66 | ||
|
+6 |
3fb90a501a | ||
|
|
56bba795cb | ||
|
|
7e2c5ad4e6 | ||
|
|
6ce0447ff6 | ||
|
|
defb9dd51c | ||
|
|
f70423bd99 | ||
|
|
1428286bd1 | ||
|
|
7608005dd7 | ||
|
|
dda15ea4b4 | ||
|
|
6dc65dbaa1 | ||
|
|
b275d3410a | ||
|
|
0e8b9b283f | ||
|
|
eb8406214e | ||
|
|
fcd60daed5 | ||
|
|
23cf75be3c | ||
|
|
d02051dbb6 | ||
|
|
23644ac6a7 |
@@ -20,6 +20,7 @@ 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/
|
||||
|
||||
@@ -59,20 +59,20 @@ jobs:
|
||||
if: steps.filter.outputs.dotnet != 'true'
|
||||
run: echo "NOT dotnet file"
|
||||
|
||||
dotnet-build-and-test:
|
||||
# Build the full solution (including samples) on all TFMs. No tests.
|
||||
dotnet-build:
|
||||
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: "net10.0", os: "ubuntu-latest", configuration: Release }
|
||||
- { targetFramework: "net9.0", os: "windows-latest", configuration: Debug }
|
||||
- { targetFramework: "net8.0", os: "ubuntu-latest", configuration: Release }
|
||||
- { targetFramework: "net472", os: "windows-latest", configuration: Release, integration-tests: true, environment: "integration" }
|
||||
- { targetFramework: "net472", os: "windows-latest", configuration: Release }
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -84,16 +84,6 @@ 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:
|
||||
@@ -140,25 +130,98 @@ jobs:
|
||||
popd
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
- name: Run Unit Tests
|
||||
shell: bash
|
||||
run: |
|
||||
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')
|
||||
# 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" }
|
||||
|
||||
# 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
|
||||
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
|
||||
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/"
|
||||
)
|
||||
}
|
||||
|
||||
dotnet test --solution ./filtered-unit.slnx `
|
||||
-f ${{ matrix.targetFramework }} `
|
||||
-c ${{ matrix.configuration }} `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
--ignore-exit-code 8 `
|
||||
@coverageArgs
|
||||
env:
|
||||
# Cosmos DB Emulator connection settings
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
@@ -185,21 +248,19 @@ jobs:
|
||||
id: azure-functions-setup
|
||||
|
||||
- name: Run Integration Tests
|
||||
shell: bash
|
||||
shell: pwsh
|
||||
working-directory: dotnet
|
||||
if: github.event_name != 'pull_request' && matrix.integration-tests
|
||||
run: |
|
||||
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
|
||||
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
|
||||
env:
|
||||
# Cosmos DB Emulator connection settings
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
@@ -222,7 +283,7 @@ jobs:
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.1
|
||||
with:
|
||||
reports: "./TestResults/Coverage/**/coverage.cobertura.xml"
|
||||
reports: "./TestResults/Coverage/**/*.cobertura.xml"
|
||||
targetdir: "./TestResults/Reports"
|
||||
reporttypes: "HtmlInline;JsonSummary"
|
||||
|
||||
@@ -236,13 +297,13 @@ jobs:
|
||||
- name: Check coverage
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
shell: pwsh
|
||||
run: .github/workflows/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
|
||||
run: ./dotnet/eng/scripts/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-and-test]
|
||||
needs: [dotnet-build, dotnet-test]
|
||||
steps:
|
||||
- name: Get Date
|
||||
shell: bash
|
||||
|
||||
+50
-5
@@ -17,14 +17,17 @@ 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 tests/Microsoft.Agents.AI.<Package>.UnitTests
|
||||
dotnet test --project tests/Microsoft.Agents.AI.<Package>.UnitTests
|
||||
dotnet format src/Microsoft.Agents.AI.<Package>
|
||||
|
||||
# Run a single test
|
||||
dotnet test --filter "FullyQualifiedName~Namespace.TestClassName.TestMethodName"
|
||||
# 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
|
||||
|
||||
# Run unit tests only
|
||||
dotnet test --filter FullyQualifiedName\~UnitTests
|
||||
# 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
|
||||
```
|
||||
|
||||
Use `--tl:off` when building to avoid flickering when running commands in the agent.
|
||||
@@ -56,7 +59,7 @@ Example: Running tests for a single project using .NET 10.
|
||||
|
||||
```bash
|
||||
# From dotnet/ directory
|
||||
dotnet test ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0
|
||||
dotnet test --project ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0
|
||||
```
|
||||
|
||||
Example: Running a single test in a specific project using .NET 10.
|
||||
@@ -64,7 +67,7 @@ Provide the full namespace, class name, and method name for the test you want to
|
||||
|
||||
```bash
|
||||
# From dotnet/ directory
|
||||
dotnet test ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0 --filter "FullyQualifiedName~Microsoft.Agents.AI.Abstractions.UnitTests.AgentRunOptionsTests.CloningConstructorCopiesProperties"
|
||||
dotnet test --project ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0 --filter-query "/*/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests/CloningConstructorCopiesProperties"
|
||||
```
|
||||
|
||||
### Multi-target framework tip
|
||||
@@ -83,3 +86,45 @@ 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 -- -?
|
||||
```
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5.1" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.8.0" />
|
||||
<!-- Identity -->
|
||||
@@ -140,12 +141,10 @@
|
||||
<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" 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" />
|
||||
<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" />
|
||||
<!-- Symbols -->
|
||||
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
|
||||
<!-- Toolset -->
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -286,6 +287,8 @@
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.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" />
|
||||
@@ -311,7 +314,6 @@
|
||||
</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/">
|
||||
@@ -348,6 +350,10 @@
|
||||
<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" />
|
||||
@@ -413,6 +419,10 @@
|
||||
<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" />
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/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,5 +3,8 @@
|
||||
"version": "10.0.100",
|
||||
"rollForward": "minor",
|
||||
"allowPrerelease": false
|
||||
},
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's
|
||||
// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive:
|
||||
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
|
||||
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
|
||||
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
|
||||
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// 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.
|
||||
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Create a chat client for the agent and a separate one for the summarization strategy.
|
||||
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
|
||||
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
|
||||
// Define a tool the agent can use, so we can see tool-result compaction in action.
|
||||
[Description("Look up the current price of a product by name.")]
|
||||
static string LookupPrice([Description("The product name to look up.")] string productName) =>
|
||||
productName.ToUpperInvariant() switch
|
||||
{
|
||||
"LAPTOP" => "The laptop costs $999.99.",
|
||||
"KEYBOARD" => "The keyboard costs $79.99.",
|
||||
"MOUSE" => "The mouse costs $29.99.",
|
||||
_ => $"Sorry, I don't have pricing for '{productName}'."
|
||||
};
|
||||
|
||||
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
|
||||
PipelineCompactionStrategy compactionPipeline =
|
||||
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
|
||||
new ToolResultCompactionStrategy(CompactionTriggers.TokensExceed(0x200)),
|
||||
|
||||
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
|
||||
|
||||
// 3. Aggressive: keep only the last N user turns and their responses
|
||||
new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
|
||||
|
||||
// 4. Emergency: drop oldest groups until under the token budget
|
||||
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
|
||||
|
||||
// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline.
|
||||
AIAgent agent =
|
||||
agentChatClient.AsAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ShoppingAssistant",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful, but long winded, shopping assistant.
|
||||
Help the user look up prices and compare products.
|
||||
When responding, Be sure to be extra descriptive and use as
|
||||
many words as possible without sounding ridiculous.
|
||||
""",
|
||||
Tools = [AIFunctionFactory.Create(LookupPrice)],
|
||||
},
|
||||
CompactionStrategy = compactionPipeline,
|
||||
});
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Helper to print chat history size
|
||||
void PrintChatHistory()
|
||||
{
|
||||
if (session.TryGetInMemoryChatHistory(out var history))
|
||||
{
|
||||
Console.WriteLine($" [Chat history: {history.Count} messages]\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Run a multi-turn conversation with tool calls to exercise the pipeline.
|
||||
string[] prompts =
|
||||
[
|
||||
"What's the price of a laptop?",
|
||||
"How about a keyboard?",
|
||||
"And a mouse?",
|
||||
"Which product is the cheapest?",
|
||||
"Can you compare the laptop and the keyboard for me?",
|
||||
"What was the first product I asked about?",
|
||||
"Thank you!",
|
||||
];
|
||||
|
||||
foreach (string prompt in prompts)
|
||||
{
|
||||
Console.WriteLine($"User: {prompt}");
|
||||
Console.WriteLine($"Agent: {await agent.RunAsync(prompt, session)}");
|
||||
|
||||
PrintChatHistory();
|
||||
}
|
||||
@@ -75,10 +75,15 @@ 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.AddScoped<IUserContext, KeycloakUserContext>();
|
||||
builder.Services.AddScoped<ExpenseService>();
|
||||
builder.Services.AddScoped<AIAgent>(sp =>
|
||||
builder.Services.AddSingleton<IUserContext, KeycloakUserContext>();
|
||||
builder.Services.AddSingleton<ExpenseService>();
|
||||
builder.Services.AddSingleton<AIAgent>(sp =>
|
||||
{
|
||||
var expenseService = sp.GetRequiredService<ExpenseService>();
|
||||
|
||||
|
||||
@@ -27,43 +27,73 @@ 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 scoped service so it is resolved once per request.
|
||||
/// Registered as a singleton — claims are parsed once per request and
|
||||
/// cached in <see cref="HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
public sealed class KeycloakUserContext : IUserContext
|
||||
{
|
||||
public string UserId { get; }
|
||||
private static readonly object s_cacheKey = new();
|
||||
|
||||
public string UserName { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public IReadOnlySet<string> Scopes { get; }
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public KeycloakUserContext(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
|
||||
this._httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user?.FindFirstValue("sub")
|
||||
?? "anonymous";
|
||||
public string UserId => this.GetOrCreateCachedInfo().UserId;
|
||||
|
||||
this.UserName = user?.FindFirstValue("preferred_username")
|
||||
?? user?.FindFirstValue(ClaimTypes.Name)
|
||||
?? "unknown";
|
||||
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";
|
||||
|
||||
string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName);
|
||||
string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname);
|
||||
this.DisplayName = (givenName, familyName) switch
|
||||
string displayName = (givenName, familyName) switch
|
||||
{
|
||||
(not null, not null) => $"{givenName} {familyName}",
|
||||
(not null, null) => givenName,
|
||||
(null, not null) => familyName,
|
||||
_ => this.UserName,
|
||||
_ => userName,
|
||||
};
|
||||
|
||||
string? scopeClaim = user?.FindFirstValue("scope");
|
||||
this.Scopes = scopeClaim is not null
|
||||
IReadOnlySet<string> 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Build the application
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy files from the current directory on the host to the working directory in the container
|
||||
COPY . .
|
||||
|
||||
RUN dotnet restore
|
||||
RUN dotnet build -c Release --no-restore
|
||||
RUN dotnet publish -c Release --no-build -o /app -f net10.0
|
||||
|
||||
# Run the application
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything needed to run the app from the "build" stage.
|
||||
COPY --from=build /app .
|
||||
|
||||
EXPOSE 8088
|
||||
ENTRYPOINT ["dotnet", "FoundryMultiAgent.dll"]
|
||||
@@ -0,0 +1,76 @@
|
||||
<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>
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
**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/)
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"AZURE_AI_PROJECT_ENDPOINT": "https://<your-resource>.services.ai.azure.com/api/projects/<your-project>",
|
||||
"MODEL_DEPLOYMENT_NAME": "gpt-4o-mini"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Build the application
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy files from the current directory on the host to the working directory in the container
|
||||
COPY . .
|
||||
|
||||
RUN dotnet restore
|
||||
RUN dotnet build -c Release --no-restore
|
||||
RUN dotnet publish -c Release --no-build -o /app -f net10.0
|
||||
|
||||
# Run the application
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything needed to run the app from the "build" stage.
|
||||
COPY --from=build /app .
|
||||
|
||||
EXPOSE 8088
|
||||
ENTRYPOINT ["dotnet", "FoundrySingleAgent.dll"]
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<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>
|
||||
@@ -0,0 +1,128 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,167 @@
|
||||
**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/)
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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
|
||||
@@ -0,0 +1,52 @@
|
||||
@host = http://localhost:8088
|
||||
@endpoint = {{host}}/responses
|
||||
|
||||
### Health Check
|
||||
GET {{host}}/readiness
|
||||
|
||||
### Simple hotel search - budget under $200
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night",
|
||||
"stream": false
|
||||
}
|
||||
|
||||
### Hotel search with higher budget
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": "Find me hotels in Seattle for March 20-23, 2025 under $250 per night",
|
||||
"stream": false
|
||||
}
|
||||
|
||||
### Ask for recommendations without dates (agent should ask for clarification)
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": "What hotels do you recommend in Seattle?",
|
||||
"stream": false
|
||||
}
|
||||
|
||||
### Explicit input format
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "I'm looking for a hotel in Seattle from 2025-04-01 to 2025-04-05, my budget is $150 per night maximum"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stream": false
|
||||
}
|
||||
@@ -12,6 +12,8 @@ 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
|
||||
|
||||
@@ -38,9 +40,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 | Azure AI Foundry project endpoint |
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithTools, AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Azure AI Foundry project endpoint |
|
||||
| `MCP_TOOL_CONNECTION_ID` | AgentWithTools | Foundry MCP tool connection name |
|
||||
| `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools | Chat model deployment name (defaults to `gpt-4o-mini`) |
|
||||
| `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Chat model deployment name (defaults to `gpt-4o-mini`) |
|
||||
|
||||
See each sample's README for the specific variables required.
|
||||
|
||||
|
||||
@@ -79,20 +79,21 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
|
||||
public void SetMessages(AgentSession? session, List<ChatMessage> messages)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
Throw.IfNull(messages);
|
||||
|
||||
var state = this._sessionState.GetOrInitializeState(session);
|
||||
State state = this._sessionState.GetOrInitializeState(session);
|
||||
state.Messages = messages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
|
||||
{
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
// Apply pre-invocation compaction strategy if configured
|
||||
await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return state.Messages;
|
||||
@@ -101,18 +102,31 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
// Add request and response messages to the provider
|
||||
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
|
||||
state.Messages.AddRange(allNewMessages);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded)
|
||||
{
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
// Apply pre-write compaction strategy if configured
|
||||
await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompactMessagesAsync(State state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this.ChatReducer is not null)
|
||||
{
|
||||
// ChatReducer takes precedence, if configured
|
||||
state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
|
||||
return;
|
||||
}
|
||||
|
||||
// %%% TODO: CONSIDER COMPACTION
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -346,14 +346,14 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
|
||||
internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
|
||||
{
|
||||
TextContent textContent = new(assistantMessage.Data?.Content ?? string.Empty)
|
||||
AIContent content = new()
|
||||
{
|
||||
RawRepresentation = assistantMessage
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [textContent])
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [content])
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = assistantMessage.Data?.MessageId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -45,6 +46,22 @@ public sealed class ChatClientAgentOptions
|
||||
/// </summary>
|
||||
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="CompactionStrategy"/> to use for in-run context compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set, this strategy is applied to the message list before each call to the underlying
|
||||
/// <see cref="IChatClient"/> during agent execution. This keeps the context within token limits
|
||||
/// as tool calls accumulate during long-running agent invocations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The strategy organizes messages into atomic groups (preserving tool-call/result pairings)
|
||||
/// before applying compaction logic. See <see cref="CompactionStrategy"/> for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public CompactionStrategy? CompactionStrategy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
|
||||
/// without applying any default decorators.
|
||||
@@ -101,6 +118,7 @@ public sealed class ChatClientAgentOptions
|
||||
ChatOptions = this.ChatOptions?.Clone(),
|
||||
ChatHistoryProvider = this.ChatHistoryProvider,
|
||||
AIContextProviders = this.AIContextProviders is null ? null : new List<AIContextProvider>(this.AIContextProviders),
|
||||
CompactionStrategy = this.CompactionStrategy,
|
||||
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
|
||||
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
|
||||
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
|
||||
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -53,9 +54,16 @@ public static class ChatClientExtensions
|
||||
{
|
||||
var chatBuilder = chatClient.AsBuilder();
|
||||
|
||||
// Add compaction as the innermost middleware so it runs before every LLM call,
|
||||
// including those triggered by tool call iterations within FunctionInvokingChatClient.
|
||||
if (options?.CompactionStrategy is { } compactionStrategy)
|
||||
{
|
||||
chatBuilder.Use(innerClient => new CompactingChatClient(innerClient, compactionStrategy));
|
||||
}
|
||||
|
||||
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
_ = chatBuilder.Use((innerClient, services) =>
|
||||
chatBuilder.Use((innerClient, services) =>
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="IChatClient"/> that applies an <see cref="CompactionStrategy"/> to the message list
|
||||
/// before each call to the inner chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This client is used for in-run compaction during the tool loop. It is inserted into the
|
||||
/// <see cref="IChatClient"/> pipeline before the `FunctionInvokingChatClient` so that
|
||||
/// compaction is applied before every LLM call, including those triggered by tool call iterations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings)
|
||||
/// before applying compaction logic. Only included messages are forwarded to the inner client.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class CompactingChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly CompactionStrategy _compactionStrategy;
|
||||
private readonly ProviderSessionState<State> _sessionState;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactingChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The inner chat client to delegate to.</param>
|
||||
/// <param name="compactionStrategy">The compaction strategy to apply before each call.</param>
|
||||
public CompactingChatClient(IChatClient innerClient, CompactionStrategy compactionStrategy)
|
||||
: base(innerClient)
|
||||
{
|
||||
this._compactionStrategy = Throw.IfNull(compactionStrategy);
|
||||
this._sessionState = new ProviderSessionState<State>(
|
||||
_ => new State(),
|
||||
Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode())),
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
return await base.GetResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
Throw.IfNull(serviceType);
|
||||
|
||||
return
|
||||
serviceKey is null && serviceType.IsInstanceOfType(typeof(CompactionStrategy)) ?
|
||||
this._compactionStrategy :
|
||||
base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<ChatMessage>> ApplyCompactionAsync(
|
||||
IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages]; // %%% TODO - LIST COPY
|
||||
|
||||
AgentRunContext? currentAgentContext = AIAgent.CurrentRunContext;
|
||||
if (currentAgentContext is null ||
|
||||
currentAgentContext.Session is null)
|
||||
{
|
||||
// No session available — no reason to compact
|
||||
return messages;
|
||||
}
|
||||
|
||||
State state = this._sessionState.GetOrInitializeState(currentAgentContext.Session);
|
||||
|
||||
MessageIndex messageIndex;
|
||||
if (state.MessageIndex.Count > 0)
|
||||
{
|
||||
// Update existing index
|
||||
messageIndex = new(state.MessageIndex);
|
||||
messageIndex.Update(messageList);
|
||||
}
|
||||
else
|
||||
{
|
||||
// First pass — initialize message index state
|
||||
messageIndex = MessageIndex.Create(messageList);
|
||||
}
|
||||
|
||||
// Apply compaction
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
bool wasCompacted = await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
Debug.WriteLine($"COMPACTION: {wasCompacted} - {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
if (wasCompacted)
|
||||
{
|
||||
state.MessageIndex = [.. messageIndex.Groups]; // %%% TODO - LIST COPY
|
||||
}
|
||||
|
||||
return wasCompacted ? messageIndex.GetIncludedMessages() : messageList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
public sealed class State
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the message index.
|
||||
/// </summary>
|
||||
[JsonPropertyName("messages")]
|
||||
public List<MessageGroup> MessageIndex { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for strategies that compact a <see cref="MessageIndex"/> to reduce context size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Compaction strategies operate on <see cref="MessageIndex"/> instances, which organize messages
|
||||
/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
|
||||
/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every strategy requires a <see cref="CompactionTrigger"/> that determines whether compaction should
|
||||
/// proceed based on current <see cref="MessageIndex"/> metrics (token count, message count, turn count, etc.).
|
||||
/// The base class evaluates this trigger at the start of <see cref="CompactAsync"/> and skips compaction when
|
||||
/// the trigger returns <see langword="false"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An optional <b>target</b> condition controls when compaction stops. Strategies incrementally exclude
|
||||
/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
|
||||
/// <see langword="true"/>. When no target is specified, it defaults to the inverse of the trigger —
|
||||
/// meaning compaction stops when the trigger condition would no longer fire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Strategies can be applied at three lifecycle points:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
|
||||
/// <item><description><b>Pre-write</b>: Before persisting messages to storage via <see cref="ChatHistoryProvider"/>.</description></item>
|
||||
/// <item><description><b>On existing storage</b>: As a maintenance operation to compact stored history.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="MessageIndex"/>
|
||||
/// via <see cref="PipelineCompactionStrategy"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that determines whether compaction should proceed.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. Strategies re-evaluate
|
||||
/// this predicate after each incremental exclusion and stop when it returns <see langword="true"/>.
|
||||
/// When <see langword="null"/>, defaults to the inverse of the <paramref name="trigger"/> — compaction
|
||||
/// stops as soon as the trigger condition would no longer fire.
|
||||
/// </param>
|
||||
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
|
||||
{
|
||||
this.Trigger = Throw.IfNull(trigger);
|
||||
this.Target = target ?? (index => !trigger(index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the trigger predicate that controls when compaction proceeds.
|
||||
/// </summary>
|
||||
protected CompactionTrigger Trigger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the target predicate that controls when compaction stops.
|
||||
/// Strategies re-evaluate this after each incremental exclusion and stop when it returns <see langword="true"/>.
|
||||
/// </summary>
|
||||
protected CompactionTrigger Target { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the <see cref="Trigger"/> and, when it fires, delegates to
|
||||
/// <see cref="ApplyCompactionAsync"/> and reports compaction metrics.
|
||||
/// </summary>
|
||||
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred, <see langword="false"/> otherwise.</returns>
|
||||
public async Task<bool> CompactAsync(MessageIndex index, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!this.Trigger(index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int beforeTokens = index.IncludedTokenCount;
|
||||
int beforeGroups = index.IncludedGroupCount;
|
||||
int beforeMessages = index.IncludedMessageCount;
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
bool compacted = await this.ApplyCompactionAsync(index, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"""
|
||||
COMPACTION: {this.GetType().Name}
|
||||
Duration {stopwatch.ElapsedMilliseconds}ms
|
||||
Messages {beforeMessages} => {index.IncludedMessageCount}
|
||||
Groups {beforeGroups} => {index.IncludedGroupCount}
|
||||
Tokens {beforeTokens} => {index.IncludedTokenCount}
|
||||
""");
|
||||
}
|
||||
|
||||
return compacted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the strategy-specific compaction logic to the specified message index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is called by <see cref="CompactAsync"/> only when the <see cref="Trigger"/>
|
||||
/// returns <see langword="true"/>. Implementations do not need to evaluate the trigger or
|
||||
/// report metrics — the base class handles both. Implementations should use <see cref="Target"/>
|
||||
/// to determine when to stop compacting incrementally.
|
||||
/// </remarks>
|
||||
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task whose result is <see langword="true"/> if any compaction was performed, <see langword="false"/> otherwise.</returns>
|
||||
protected abstract Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A predicate that evaluates whether compaction should proceed based on current <see cref="MessageIndex"/> metrics.
|
||||
/// </summary>
|
||||
/// <param name="index">The current message index with group, token, message, and turn metrics.</param>
|
||||
/// <returns><see langword="true"/> if compaction should proceed; <see langword="false"/> to skip.</returns>
|
||||
public delegate bool CompactionTrigger(MessageIndex index);
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Provides factory methods for common <see cref="CompactionTrigger"/> predicates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These triggers evaluate included (non-excluded) metrics from the <see cref="MessageIndex"/>.
|
||||
/// Combine triggers with <see cref="All"/> or <see cref="Any"/> for compound conditions,
|
||||
/// or write a custom lambda for full flexibility.
|
||||
/// </remarks>
|
||||
public static class CompactionTriggers
|
||||
{
|
||||
/// <summary>
|
||||
/// Always trigger compaction, regardless of the message index state.
|
||||
/// </summary>
|
||||
public static readonly CompactionTrigger Always =
|
||||
_ => true;
|
||||
|
||||
/// <summary>
|
||||
/// Always trigger compaction, regardless of the message index state.
|
||||
/// </summary>
|
||||
public static readonly CompactionTrigger Never =
|
||||
_ => false;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included token count is below the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The token threshold. Compaction proceeds when included tokens exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
|
||||
public static CompactionTrigger TokensBelow(int maxTokens) =>
|
||||
index => index.IncludedTokenCount < maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The token threshold. Compaction proceeds when included tokens exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
|
||||
public static CompactionTrigger TokensExceed(int maxTokens) =>
|
||||
index => index.IncludedTokenCount > maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included message count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxMessages">The message threshold. Compaction proceeds when included messages exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included message count.</returns>
|
||||
public static CompactionTrigger MessagesExceed(int maxMessages) =>
|
||||
index => index.IncludedMessageCount > maxMessages;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTurns">The turn threshold. Compaction proceeds when included turns exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included turn count.</returns>
|
||||
public static CompactionTrigger TurnsExceed(int maxTurns) =>
|
||||
index => index.IncludedTurnCount > maxTurns;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included group count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxGroups">The group threshold. Compaction proceeds when included groups exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included group count.</returns>
|
||||
public static CompactionTrigger GroupsExceed(int maxGroups) =>
|
||||
index => index.IncludedGroupCount > maxGroups;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included message index contains at least one
|
||||
/// non-excluded <see cref="MessageGroupKind.ToolCall"/> group.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included tool call presence.</returns>
|
||||
public static CompactionTrigger HasToolCalls() =>
|
||||
index => index.Groups.Any(g => !g.IsExcluded && g.Kind == MessageGroupKind.ToolCall);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compound trigger that fires only when <b>all</b> of the specified triggers fire.
|
||||
/// </summary>
|
||||
/// <param name="triggers">The triggers to combine with logical AND.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that requires all conditions to be met.</returns>
|
||||
public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
|
||||
index =>
|
||||
{
|
||||
for (int i = 0; i < triggers.Length; i++)
|
||||
{
|
||||
if (!triggers[i](index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compound trigger that fires when <b>any</b> of the specified triggers fire.
|
||||
/// </summary>
|
||||
/// <param name="triggers">The triggers to combine with logical OR.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that requires at least one condition to be met.</returns>
|
||||
public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
|
||||
index =>
|
||||
{
|
||||
for (int i = 0; i < triggers.Length; i++)
|
||||
{
|
||||
if (triggers[i](index))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a logical group of <see cref="ChatMessage"/> instances that must be kept or removed together during compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Message groups ensure atomic preservation of related messages. For example, an assistant message
|
||||
/// containing tool calls and its corresponding tool result messages form a <see cref="MessageGroupKind.ToolCall"/>
|
||||
/// group — removing one without the other would cause LLM API errors.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Groups also support exclusion semantics: a group can be marked as excluded (with an optional reason)
|
||||
/// to indicate it should not be included in the messages sent to the model, while still being preserved
|
||||
/// for diagnostics, storage, or later re-inclusion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each group tracks its <see cref="MessageCount"/>, <see cref="ByteCount"/>, and <see cref="TokenCount"/>
|
||||
/// so that <see cref="MessageIndex"/> can efficiently aggregate totals across all or only included groups.
|
||||
/// These values are computed by <see cref="MessageIndex.Create"/> and passed into the constructor.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MessageGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="ChatMessage.AdditionalProperties"/> key used to identify a message as a compaction summary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When this key is present with a value of <see langword="true"/>, the message is classified as
|
||||
/// <see cref="MessageGroupKind.Summary"/> by <see cref="MessageIndex.Create"/>.
|
||||
/// </remarks>
|
||||
public static readonly string SummaryPropertyKey = "_is_summary";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageGroup"/> class.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in this group. The list is captured as a read-only snapshot.</param>
|
||||
/// <param name="byteCount">The total UTF-8 byte count of the text content in the messages.</param>
|
||||
/// <param name="tokenCount">The token count for the messages, computed by a tokenizer or estimated.</param>
|
||||
/// <param name="turnIndex">
|
||||
/// The zero-based user turn this group belongs to, or <see langword="null"/> for groups that precede
|
||||
/// the first user message (e.g., system messages).
|
||||
/// </param>
|
||||
[JsonConstructor]
|
||||
public MessageGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int byteCount, int tokenCount, int? turnIndex = null)
|
||||
{
|
||||
this.Kind = kind;
|
||||
this.Messages = messages;
|
||||
this.MessageCount = messages.Count;
|
||||
this.ByteCount = byteCount;
|
||||
this.TokenCount = tokenCount;
|
||||
this.TurnIndex = turnIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of this message group.
|
||||
/// </summary>
|
||||
public MessageGroupKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages in this group.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> Messages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of messages in this group.
|
||||
/// </summary>
|
||||
public int MessageCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count of the text content in this group's messages.
|
||||
/// </summary>
|
||||
public int ByteCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the estimated or actual token count for this group's messages.
|
||||
/// </summary>
|
||||
public int TokenCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the zero-based user turn index this group belongs to, or <see langword="null"/>
|
||||
/// for groups that precede the first user message (e.g., system messages).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A turn starts with a <see cref="MessageGroupKind.User"/> group and includes all subsequent
|
||||
/// non-user, non-system groups until the next user group or end of conversation.
|
||||
/// </remarks>
|
||||
public int? TurnIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this group is excluded from the projected message list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Excluded groups are preserved in the collection for diagnostics or storage purposes
|
||||
/// but are not included when calling <see cref="MessageIndex.GetIncludedMessages"/>.
|
||||
/// </remarks>
|
||||
public bool IsExcluded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional reason explaining why this group was excluded.
|
||||
/// </summary>
|
||||
public string? ExcludeReason { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of a <see cref="MessageGroup"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Message groups are used to classify logically related messages that must be kept together
|
||||
/// during compaction operations. For example, an assistant message containing tool calls
|
||||
/// and its corresponding tool result messages form an atomic <see cref="ToolCall"/> group.
|
||||
/// </remarks>
|
||||
public enum MessageGroupKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A system message group containing one or more system messages.
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// A user message group containing a single user message.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An assistant message group containing a single assistant text response (no tool calls).
|
||||
/// </summary>
|
||||
AssistantText,
|
||||
|
||||
/// <summary>
|
||||
/// An atomic tool call group containing an assistant message with tool calls
|
||||
/// followed by the corresponding tool result messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This group must be treated as an atomic unit during compaction. Removing the assistant
|
||||
/// message without its tool results (or vice versa) will cause LLM API errors.
|
||||
/// </remarks>
|
||||
ToolCall,
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names
|
||||
/// <summary>
|
||||
/// A summary message group produced by a compaction strategy (e.g., <c>SummarizationCompactionStrategy</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Summary groups replace previously compacted messages with a condensed representation.
|
||||
/// They are identified by the <see cref="MessageGroup.SummaryPropertyKey"/> metadata entry
|
||||
/// on the underlying <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </remarks>
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
Summary,
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a collection of <see cref="MessageGroup"/> instances derived from a flat list of <see cref="ChatMessage"/> objects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="MessageIndex"/> provides structural grouping of messages into logical units that
|
||||
/// respect the atomic group preservation constraint: tool call assistant messages and their corresponding
|
||||
/// tool result messages are always grouped together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This collection supports exclusion-based projection, where groups can be marked as excluded
|
||||
/// without being removed, allowing compaction strategies to toggle visibility while preserving
|
||||
/// the full history for diagnostics or storage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each group tracks its own <see cref="MessageGroup.MessageCount"/>, <see cref="MessageGroup.ByteCount"/>,
|
||||
/// and <see cref="MessageGroup.TokenCount"/>. The collection provides aggregate properties for both
|
||||
/// the total (all groups) and included (non-excluded groups only) counts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Instances created via <see cref="Create"/> track internal state that enables efficient incremental
|
||||
/// updates via <see cref="Update"/>. This allows caching a <see cref="MessageIndex"/> instance and
|
||||
/// appending only new messages without reprocessing the entire history.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MessageIndex
|
||||
{
|
||||
private int _currentTurn;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of message groups in this collection.
|
||||
/// </summary>
|
||||
public IList<MessageGroup> Groups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tokenizer used for computing token counts, or <see langword="null"/> if token counts are estimated.
|
||||
/// </summary>
|
||||
public Tokenizer? Tokenizer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of raw messages that have been processed into groups.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This value is set by <see cref="Create"/> and updated by <see cref="Update"/>.
|
||||
/// It is used by <see cref="Update"/> to determine which messages are new and need processing.
|
||||
/// </remarks>
|
||||
public int ProcessedMessageCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageIndex"/> class with the specified groups.
|
||||
/// </summary>
|
||||
/// <param name="groups">The message groups.</param>
|
||||
/// <param name="tokenizer">An optional tokenizer retained for computing token counts when adding new groups.</param>
|
||||
public MessageIndex(IList<MessageGroup> groups, Tokenizer? tokenizer = null)
|
||||
{
|
||||
this.Groups = groups;
|
||||
this.Tokenizer = tokenizer;
|
||||
|
||||
// Restore turn counter from the last group that has a TurnIndex
|
||||
for (int index = groups.Count - 1; index >= 0; --index)
|
||||
{
|
||||
if (this.Groups[index].TurnIndex.HasValue)
|
||||
{
|
||||
this._currentTurn = this.Groups[index].TurnIndex!.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="MessageIndex"/> from a flat list of <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to group.</param>
|
||||
/// <param name="tokenizer">
|
||||
/// An optional <see cref="Tokenizer"/> for computing token counts on each group.
|
||||
/// When <see langword="null"/>, token counts are estimated as <c>ByteCount / 4</c>.
|
||||
/// </param>
|
||||
/// <returns>A new <see cref="MessageIndex"/> with messages organized into logical groups.</returns>
|
||||
/// <remarks>
|
||||
/// The grouping algorithm:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>System messages become <see cref="MessageGroupKind.System"/> groups.</description></item>
|
||||
/// <item><description>User messages become <see cref="MessageGroupKind.User"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages with tool calls, followed by their corresponding tool result messages, become <see cref="MessageGroupKind.ToolCall"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages marked with <see cref="MessageGroup.SummaryPropertyKey"/> become <see cref="MessageGroupKind.Summary"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages without tool calls become <see cref="MessageGroupKind.AssistantText"/> groups.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static MessageIndex Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
|
||||
{
|
||||
Debug.WriteLine("COMPACTION: Creating index x{messages.Count} messages");
|
||||
MessageIndex instance = new([], tokenizer);
|
||||
instance.AppendFromMessages(messages, 0);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incrementally updates the groups with new messages from the conversation.
|
||||
/// </summary>
|
||||
/// <param name="allMessages">
|
||||
/// The full list of messages for the conversation. This must be the same list (or a replacement with the same
|
||||
/// prefix) that was used to create or last update this instance.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If the message count exceeds <see cref="ProcessedMessageCount"/>, only the new (delta) messages
|
||||
/// are processed and appended as new groups. Existing groups and their compaction state (exclusions)
|
||||
/// are preserved, allowing compaction strategies to build on previous results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the message count is less than <see cref="ProcessedMessageCount"/> (e.g., after storage compaction
|
||||
/// replaced messages with summaries), all groups are cleared and rebuilt from scratch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the message count equals <see cref="ProcessedMessageCount"/>, no work is performed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public void Update(IList<ChatMessage> allMessages)
|
||||
{
|
||||
if (allMessages.Count == this.ProcessedMessageCount)
|
||||
{
|
||||
return; // No new messages
|
||||
}
|
||||
|
||||
if (allMessages.Count < this.ProcessedMessageCount)
|
||||
{
|
||||
// Message list shrank (e.g., after storage compaction). Rebuild from scratch.
|
||||
this.ProcessedMessageCount = 0;
|
||||
}
|
||||
|
||||
if (this.ProcessedMessageCount == 0)
|
||||
{
|
||||
// First update on a manually constructed instance — clear any pre-existing groups
|
||||
this.Groups.Clear();
|
||||
this._currentTurn = 0;
|
||||
}
|
||||
|
||||
// Process only the delta messages
|
||||
this.AppendFromMessages(allMessages, this.ProcessedMessageCount);
|
||||
}
|
||||
|
||||
private void AppendFromMessages(IList<ChatMessage> messages, int startIndex)
|
||||
{
|
||||
int index = startIndex;
|
||||
|
||||
while (index < messages.Count)
|
||||
{
|
||||
ChatMessage message = messages[index];
|
||||
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
// System messages are not part of any turn
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.System, [message], this.Tokenizer, turnIndex: null));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.User)
|
||||
{
|
||||
this._currentTurn++;
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.User, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
|
||||
{
|
||||
List<ChatMessage> groupMessages = [message];
|
||||
index++;
|
||||
|
||||
// Collect all subsequent tool result messages
|
||||
while (index < messages.Count && messages[index].Role == ChatRole.Tool)
|
||||
{
|
||||
groupMessages.Add(messages[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
|
||||
{
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
this.ProcessedMessageCount = messages.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="MessageGroup"/> with byte and token counts computed using this collection's
|
||||
/// <see cref="Tokenizer"/>, and adds it to the <see cref="Groups"/> list at the specified index.
|
||||
/// </summary>
|
||||
/// <param name="index">The zero-based index at which the group should be inserted.</param>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in the group.</param>
|
||||
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
|
||||
/// <returns>The newly created <see cref="MessageGroup"/>.</returns>
|
||||
public MessageGroup InsertGroup(int index, MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Insert(index, group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="MessageGroup"/> with byte and token counts computed using this collection's
|
||||
/// <see cref="Tokenizer"/>, and appends it to the end of the <see cref="Groups"/> list.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in the group.</param>
|
||||
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
|
||||
/// <returns>The newly created <see cref="MessageGroup"/>.</returns>
|
||||
public MessageGroup AddGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the messages from groups that are not excluded.
|
||||
/// </summary>
|
||||
/// <returns>A list of <see cref="ChatMessage"/> instances from included groups, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetIncludedMessages() =>
|
||||
this.Groups.Where(group => !group.IsExcluded).SelectMany(group => group.Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all messages from all groups, including excluded ones.
|
||||
/// </summary>
|
||||
/// <returns>A list of all <see cref="ChatMessage"/> instances, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetAllMessages() => this.Groups.SelectMany(group => group.Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalGroupCount => this.Groups.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalMessageCount => this.Groups.Sum(g => g.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalByteCount => this.Groups.Sum(g => g.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalTokenCount => this.Groups.Sum(g => g.TokenCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups that are not excluded.
|
||||
/// </summary>
|
||||
public int IncludedGroupCount => this.Groups.Count(g => !g.IsExcluded);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedMessageCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedByteCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedTokenCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.TokenCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of user turns across all groups (including those with excluded groups).
|
||||
/// </summary>
|
||||
public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of user turns that have at least one non-excluded group.
|
||||
/// </summary>
|
||||
public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded).Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all groups that belong to the specified user turn.
|
||||
/// </summary>
|
||||
/// <param name="turnIndex">The zero-based turn index.</param>
|
||||
/// <returns>The groups belonging to the turn, in order.</returns>
|
||||
public IEnumerable<MessageGroup> GetTurnGroups(int turnIndex) =>
|
||||
this.Groups.Where(g => g.TurnIndex == turnIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the UTF-8 byte count for a set of messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compute byte count for.</param>
|
||||
/// <returns>The total UTF-8 byte count of all message text content.</returns>
|
||||
public static int ComputeByteCount(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
string text = messages[i].Text ?? string.Empty;
|
||||
if (text.Length > 0)
|
||||
{
|
||||
total += Encoding.UTF8.GetByteCount(text);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the token count for a set of messages using the specified tokenizer.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compute token count for.</param>
|
||||
/// <param name="tokenizer">The tokenizer to use for counting tokens.</param>
|
||||
/// <returns>The total token count across all message text content.</returns>
|
||||
public static int ComputeTokenCount(IReadOnlyList<ChatMessage> messages, Tokenizer tokenizer)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
string text = messages[i].Text ?? string.Empty;
|
||||
if (text.Length > 0)
|
||||
{
|
||||
total += tokenizer.CountTokens(text);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static MessageGroup CreateGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, Tokenizer? tokenizer, int? turnIndex)
|
||||
{
|
||||
int byteCount = ComputeByteCount(messages);
|
||||
int tokenCount = tokenizer is not null
|
||||
? ComputeTokenCount(messages, tokenizer)
|
||||
: byteCount / 4;
|
||||
|
||||
return new MessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
|
||||
}
|
||||
|
||||
private static bool HasToolCalls(ChatMessage message)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsSummaryMessage(ChatMessage message)
|
||||
{
|
||||
return message.AdditionalProperties?.TryGetValue(MessageGroup.SummaryPropertyKey, out object? value) is true
|
||||
&& value is true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that executes a sequential pipeline of <see cref="CompactionStrategy"/> instances
|
||||
/// against the same <see cref="MessageIndex"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
|
||||
/// such as summarizing older messages first and then truncating to fit a token budget.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pipeline's own <see cref="CompactionStrategy.Trigger"/> is evaluated first. If it returns
|
||||
/// <see langword="false"/>, none of the child strategies are executed. Each child strategy also
|
||||
/// evaluates its own trigger independently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PipelineCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="strategies">The ordered sequence of strategies to execute.</param>
|
||||
public PipelineCompactionStrategy(params IEnumerable<CompactionStrategy> strategies)
|
||||
: base(CompactionTriggers.Always)
|
||||
{
|
||||
this.Strategies = [.. Throw.IfNull(strategies)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered list of strategies in this pipeline.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CompactionStrategy> Strategies { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
bool anyCompacted = false;
|
||||
|
||||
foreach (CompactionStrategy strategy in this.Strategies)
|
||||
{
|
||||
bool compacted = await strategy.CompactAsync(index, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
anyCompacted = true;
|
||||
}
|
||||
}
|
||||
|
||||
return anyCompacted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that removes the oldest user turns and their associated response groups
|
||||
/// to bound conversation length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy always preserves system messages. It identifies user turns in the
|
||||
/// conversation (via <see cref="MessageGroup.TurnIndex"/>) and excludes the oldest turns
|
||||
/// one at a time until the <see cref="CompactionStrategy.Target"/> condition is met.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This strategy is more predictable than token-based truncation for bounding conversation
|
||||
/// length, since it operates on logical turn boundaries rather than estimated token counts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent non-system groups to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// Use <see cref="CompactionTriggers.TurnsExceed"/> for turn-based thresholds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreserved">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not exclude groups beyond this limit,
|
||||
/// regardless of the target condition.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreserved = minimumPreserved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreserved { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
// Identify protected groups: the N most-recent non-system, non-excluded groups
|
||||
List<int> nonSystemIncludedIndices = [];
|
||||
foreach (MessageGroup group in index.Groups)
|
||||
{
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
nonSystemIncludedIndices.Add(index.Groups.IndexOf(group));
|
||||
}
|
||||
}
|
||||
|
||||
int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved);
|
||||
HashSet<int> protectedGroupIndices = [];
|
||||
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
|
||||
{
|
||||
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
|
||||
}
|
||||
|
||||
// Collect distinct included turn indices in order (oldest first), excluding protected groups
|
||||
List<int> excludableTurns = [];
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded
|
||||
&& group.Kind != MessageGroupKind.System
|
||||
&& !protectedGroupIndices.Contains(i)
|
||||
&& group.TurnIndex is int turnIndex
|
||||
&& !excludableTurns.Contains(turnIndex))
|
||||
{
|
||||
excludableTurns.Add(turnIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude one turn at a time from oldest, re-checking target after each
|
||||
bool compacted = false;
|
||||
|
||||
for (int t = 0; t < excludableTurns.Count; t++)
|
||||
{
|
||||
int turnToExclude = excludableTurns[t];
|
||||
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded
|
||||
&& group.Kind != MessageGroupKind.System
|
||||
&& !protectedGroupIndices.Contains(i)
|
||||
&& group.TurnIndex == turnToExclude)
|
||||
{
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
|
||||
}
|
||||
}
|
||||
|
||||
compacted = true;
|
||||
|
||||
// Stop when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(compacted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that uses an LLM to summarize older portions of the conversation,
|
||||
/// replacing them with a single summary message that preserves key facts and context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy protects system messages and the most recent <see cref="MinimumPreserved"/>
|
||||
/// non-system groups. All older groups are collected and sent to the <see cref="IChatClient"/>
|
||||
/// for summarization. The resulting summary replaces those messages as a single assistant message
|
||||
/// with <see cref="MessageGroupKind.Summary"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
|
||||
/// When <see langword="null"/>, the strategy compacts whenever there are groups older than the preserve window.
|
||||
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SummarizationCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default summarization prompt used when none is provided.
|
||||
/// </summary>
|
||||
public const string DefaultSummarizationPrompt =
|
||||
"""
|
||||
You are a conversation summarizer. Produce a concise summary of the conversation that preserves:
|
||||
|
||||
- Key facts, decisions, and user preferences
|
||||
- Important context needed for future turns
|
||||
- Tool call outcomes and their significance
|
||||
|
||||
Omit pleasantries and redundant exchanges. Be factual and brief.
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The <see cref="IChatClient"/> to use for generating summaries. A smaller, faster model is recommended.</param>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreserved">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not summarize groups beyond this limit,
|
||||
/// regardless of the target condition. Defaults to 4, preserving the current and recent exchanges.
|
||||
/// </param>
|
||||
/// <param name="summarizationPrompt">
|
||||
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
|
||||
/// <see cref="DefaultSummarizationPrompt"/> is used.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public SummarizationCompactionStrategy(
|
||||
IChatClient chatClient,
|
||||
CompactionTrigger trigger,
|
||||
int minimumPreserved = 4,
|
||||
string? summarizationPrompt = null,
|
||||
CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.ChatClient = Throw.IfNull(chatClient);
|
||||
this.MinimumPreserved = minimumPreserved;
|
||||
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat client used for generating summaries.
|
||||
/// </summary>
|
||||
public IChatClient ChatClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreserved { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prompt used when requesting summaries from the chat client.
|
||||
/// </summary>
|
||||
public string SummarizationPrompt { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
// Count non-system, non-excluded groups to determine which are protected
|
||||
int nonSystemIncludedCount = 0;
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
nonSystemIncludedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
int protectedFromEnd = Math.Min(this.MinimumPreserved, nonSystemIncludedCount);
|
||||
int maxSummarizable = nonSystemIncludedCount - protectedFromEnd;
|
||||
|
||||
if (maxSummarizable <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark oldest non-system groups for summarization one at a time until the target is met
|
||||
StringBuilder conversationText = new();
|
||||
int summarized = 0;
|
||||
int insertIndex = -1;
|
||||
|
||||
for (int i = 0; i < index.Groups.Count && summarized < maxSummarizable; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (insertIndex < 0)
|
||||
{
|
||||
insertIndex = i;
|
||||
}
|
||||
|
||||
// Build text representation of the group for summarization
|
||||
foreach (ChatMessage message in group.Messages)
|
||||
{
|
||||
string text = message.Text ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
conversationText.AppendLine($"{message.Role}: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
|
||||
summarized++;
|
||||
|
||||
// Stop marking when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summary using the chat client (single LLM call for all marked groups)
|
||||
ChatResponse response = await this.ChatClient.GetResponseAsync(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, this.SummarizationPrompt),
|
||||
.. index.Groups
|
||||
.Where(g => !g.IsExcluded && g.Kind == MessageGroupKind.System)
|
||||
.SelectMany(g => g.Messages),
|
||||
new ChatMessage(ChatRole.User, conversationText.ToString()),
|
||||
new ChatMessage(ChatRole.User, "Summarize the conversation above concisely."),
|
||||
],
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
|
||||
|
||||
// Insert a summary group at the position of the first summarized group
|
||||
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}");
|
||||
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
|
||||
|
||||
index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that collapses old tool call groups into single concise assistant
|
||||
/// messages, removing the detailed tool results while preserving a record of which tools were called.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the gentlest compaction strategy — it does not remove any user messages or
|
||||
/// plain assistant responses. It only targets <see cref="MessageGroupKind.ToolCall"/>
|
||||
/// groups outside the protected recent window, replacing each multi-message group
|
||||
/// (assistant call + tool results) with a single assistant message like
|
||||
/// <c>[Tool calls: get_weather, search_docs]</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
|
||||
/// When <see langword="null"/>, a default compound trigger of
|
||||
/// <see cref="CompactionTriggers.TokensExceed"/> AND <see cref="CompactionTriggers.HasToolCalls"/>
|
||||
/// is used.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent non-system groups to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreserved">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not collapse groups beyond this limit,
|
||||
/// regardless of the target condition.
|
||||
/// Defaults to <see cref="DefaultMinimumPreserved"/>, ensuring the current turn's tool interactions remain visible.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreserved = minimumPreserved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreserved { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
// Identify protected groups: the N most-recent non-system, non-excluded groups
|
||||
List<int> nonSystemIncludedIndices = [];
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
nonSystemIncludedIndices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved);
|
||||
HashSet<int> protectedGroupIndices = [];
|
||||
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
|
||||
{
|
||||
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
|
||||
}
|
||||
|
||||
// Collect eligible tool groups in order (oldest first)
|
||||
List<int> eligibleIndices = [];
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind == MessageGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
|
||||
{
|
||||
eligibleIndices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (eligibleIndices.Count == 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Collapse one tool group at a time from oldest, re-checking target after each
|
||||
bool compacted = false;
|
||||
int offset = 0;
|
||||
|
||||
for (int e = 0; e < eligibleIndices.Count; e++)
|
||||
{
|
||||
int idx = eligibleIndices[e] + offset;
|
||||
MessageGroup group = index.Groups[idx];
|
||||
|
||||
// Extract tool names from FunctionCallContent
|
||||
List<string> toolNames = [];
|
||||
foreach (ChatMessage message in group.Messages)
|
||||
{
|
||||
if (message.Contents is not null)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent fcc)
|
||||
{
|
||||
toolNames.Add(fcc.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude the original group and insert a collapsed replacement
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
|
||||
|
||||
string summary = $"[Tool calls: {string.Join(", ", toolNames)}]";
|
||||
index.InsertGroup(idx + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex);
|
||||
offset++; // Each insertion shifts subsequent indices by 1
|
||||
|
||||
compacted = true;
|
||||
|
||||
// Stop when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(compacted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that removes the oldest non-system message groups,
|
||||
/// keeping at least <see cref="MinimumPreserved"/> most-recent groups intact.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy preserves system messages and removes the oldest non-system message groups first.
|
||||
/// It respects atomic group boundaries — an assistant message with tool calls and its
|
||||
/// corresponding tool result messages are always removed together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> controls when compaction proceeds.
|
||||
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or group thresholds.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TruncationCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent non-system groups to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreserved">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not remove groups beyond this limit,
|
||||
/// regardless of the target condition.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreserved = minimumPreserved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system message groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreserved { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
// Count removable (non-system, non-excluded) groups
|
||||
int removableCount = 0;
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
removableCount++;
|
||||
}
|
||||
}
|
||||
|
||||
int maxRemovable = removableCount - this.MinimumPreserved;
|
||||
if (maxRemovable <= 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Exclude oldest non-system groups one at a time, re-checking target after each
|
||||
bool compacted = false;
|
||||
int removed = 0;
|
||||
for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
|
||||
removed++;
|
||||
compacted = true;
|
||||
|
||||
// Stop when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(compacted);
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -36,7 +40,7 @@
|
||||
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Declarative.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests"/>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Integration Tests Azure Credentials
|
||||
|
||||
Adds a helper for loading Azure credentials in integration tests.
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestAzureCredentialsCode>true</InjectSharedIntegrationTestAzureCredentialsCode>
|
||||
</PropertyGroup>
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
// 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,11 +15,15 @@ public abstract class AgentTests<TAgentFixture>(Func<TAgentFixture> createAgentF
|
||||
{
|
||||
protected TAgentFixture Fixture { get; private set; } = default!;
|
||||
|
||||
public Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
this.Fixture = createAgentFixture();
|
||||
return this.Fixture.InitializeAsync();
|
||||
await this.Fixture.InitializeAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => this.Fixture.DisposeAsync();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
await this.Fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);CS8793</NoWarn>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
+4
-17
@@ -1,26 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AnthropicChatCompletion.IntegrationTests;
|
||||
|
||||
public abstract class SkipAllChatClientRunStreaming(Func<AnthropicChatCompletionFixture> func) : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(func)
|
||||
{
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync()
|
||||
=> base.RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync();
|
||||
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
=> base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
|
||||
}
|
||||
public class AnthropicBetaChatCompletionChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
|
||||
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
public class AnthropicChatCompletionChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, 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));
|
||||
public class AnthropicChatCompletionChatClientAgentReasoningRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
|
||||
+4
-17
@@ -1,30 +1,17 @@
|
||||
// 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()
|
||||
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
|
||||
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunTests()
|
||||
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
|
||||
public class AnthropicChatCompletionChatClientAgentRunTests()
|
||||
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
|
||||
public class AnthropicChatCompletionChatClientAgentReasoningRunTests()
|
||||
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
|
||||
+10
-3
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -102,9 +103,15 @@ public class AnthropicChatCompletionFixture : IChatClientAgentFixture
|
||||
// Chat Completion does not require/support deleting sessions, so this is a no-op.
|
||||
Task.CompletedTask;
|
||||
|
||||
public async Task InitializeAsync() =>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
|
||||
this._agent = await this.CreateChatClientAgentAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() =>
|
||||
Task.CompletedTask;
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-24
@@ -1,37 +1,17 @@
|
||||
// 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()
|
||||
: SkipAllRunStreaming(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
|
||||
public class AnthropicBetaChatCompletionReasoningRunStreamingTests()
|
||||
: SkipAllRunStreaming(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
|
||||
public class AnthropicChatCompletionRunStreamingTests()
|
||||
: SkipAllRunStreaming(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
|
||||
public class AnthropicChatCompletionReasoningRunStreamingTests()
|
||||
: SkipAllRunStreaming(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
|
||||
+4
-24
@@ -1,37 +1,17 @@
|
||||
// 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()
|
||||
: SkipAllRun(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
|
||||
public class AnthropicBetaChatCompletionReasoningRunTests()
|
||||
: SkipAllRun(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
|
||||
public class AnthropicChatCompletionRunTests()
|
||||
: SkipAllRun(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
|
||||
public class AnthropicChatCompletionReasoningRunTests()
|
||||
: SkipAllRun(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
|
||||
+6
-2
@@ -22,9 +22,11 @@ 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(Skip = SkipReason)]
|
||||
[Fact]
|
||||
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);
|
||||
@@ -51,9 +53,11 @@ public sealed class AnthropicSkillsIntegrationTests
|
||||
Assert.NotEmpty(response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
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()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
Assert.Skip("No messages is not supported");
|
||||
return base.RunWithNoMessageDoesNotFailAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
Assert.Skip("No messages is not supported");
|
||||
return base.RunWithNoMessageDoesNotFailAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientAgentRunPreviousResponseTests() : RunTests<AIProjectClientFixture>(() => new())
|
||||
{
|
||||
[Fact(Skip = "No messages is not supported")]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
Assert.Skip("No messages is not supported");
|
||||
return base.RunWithNoMessageDoesNotFailAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
Assert.Skip("No messages is not supported");
|
||||
return base.RunWithNoMessageDoesNotFailAsync();
|
||||
}
|
||||
}
|
||||
|
||||
+17
-12
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
@@ -66,17 +65,23 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu
|
||||
Assert.Equal("Paris", response.Result.Name);
|
||||
}
|
||||
|
||||
[Fact(Skip = NotSupported)]
|
||||
public override Task RunWithGenericTypeReturnsExpectedResultAsync() =>
|
||||
base.RunWithGenericTypeReturnsExpectedResultAsync();
|
||||
public override Task RunWithGenericTypeReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.Skip(NotSupported);
|
||||
return base.RunWithGenericTypeReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = NotSupported)]
|
||||
public override Task RunWithResponseFormatReturnsExpectedResultAsync() =>
|
||||
base.RunWithResponseFormatReturnsExpectedResultAsync();
|
||||
public override Task RunWithResponseFormatReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.Skip(NotSupported);
|
||||
return base.RunWithResponseFormatReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = NotSupported)]
|
||||
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() =>
|
||||
base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
|
||||
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.Skip(NotSupported);
|
||||
return base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -84,7 +89,7 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu
|
||||
/// </summary>
|
||||
public class AIProjectClientStructuredOutputFixture<T> : AIProjectClientFixture
|
||||
{
|
||||
public override Task InitializeAsync()
|
||||
public override async ValueTask InitializeAsync()
|
||||
{
|
||||
var agentOptions = new ChatClientAgentOptions
|
||||
{
|
||||
@@ -94,6 +99,6 @@ public class AIProjectClientStructuredOutputFixture<T> : AIProjectClientFixture
|
||||
},
|
||||
};
|
||||
|
||||
return this.InitializeAsync(agentOptions);
|
||||
await this.InitializeAsync(agentOptions);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@ namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AIProjectClientFixture>(() => new())
|
||||
{
|
||||
[Fact(Skip = "No messages is not supported")]
|
||||
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
Assert.Skip("No messages is not supported");
|
||||
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientChatClientAgentRunTests() : ChatClientAgentRunTests<AIProjectClientFixture>(() => new())
|
||||
{
|
||||
[Fact(Skip = "No messages is not supported")]
|
||||
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
Assert.Skip("No messages is not supported");
|
||||
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -17,7 +16,7 @@ namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientCreateTests
|
||||
{
|
||||
private readonly AIProjectClient _client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), new AzureCliCredential());
|
||||
private readonly AIProjectClient _client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
|
||||
@@ -8,7 +8,6 @@ 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;
|
||||
@@ -156,25 +155,27 @@ public class AIProjectClientFixture : IChatClientAgentFixture
|
||||
}
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
if (this._client is not null && this._agent is not null)
|
||||
{
|
||||
return this._client.Agents.DeleteAgentAsync(this._agent.Name);
|
||||
return new ValueTask(this._client.Agents.DeleteAgentAsync(this._agent.Name));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
return default;
|
||||
}
|
||||
|
||||
public virtual async Task InitializeAsync()
|
||||
public virtual async ValueTask InitializeAsync()
|
||||
{
|
||||
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), new AzureCliCredential());
|
||||
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
this._agent = await this.CreateChatClientAgentAsync();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(ChatClientAgentOptions options)
|
||||
{
|
||||
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), new AzureCliCredential());
|
||||
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
this._agent = await this.CreateChatClientAgentAsync(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);CS8793</NoWarn>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+2
@@ -1,7 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);CS8793</NoWarn>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-2
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -15,7 +14,7 @@ namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsPersistentCreateTests
|
||||
{
|
||||
private readonly PersistentAgentsClient _persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), new AzureCliCredential());
|
||||
private readonly PersistentAgentsClient _persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
|
||||
+8
-6
@@ -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,19 +84,21 @@ public class AzureAIAgentsPersistentFixture : IChatClientAgentFixture
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
if (this._persistentAgentsClient is not null && this._agent is not null)
|
||||
{
|
||||
return this._persistentAgentsClient.Administration.DeleteAgentAsync(this._agent.Id);
|
||||
return new ValueTask(this._persistentAgentsClient.Administration.DeleteAgentAsync(this._agent.Id));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
return default;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
this._persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), new AzureCliCredential());
|
||||
this._persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
this._agent = await this.CreateChatClientAgentAsync();
|
||||
}
|
||||
}
|
||||
|
||||
+15
-9
@@ -9,15 +9,21 @@ public class AzureAIAgentsPersistentStructuredOutputRunTests() : StructuredOutpu
|
||||
{
|
||||
private const string SkipReason = "Fails intermittently on the build agent/CI";
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public override Task RunWithResponseFormatReturnsExpectedResultAsync() =>
|
||||
base.RunWithResponseFormatReturnsExpectedResultAsync();
|
||||
public override Task RunWithResponseFormatReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
|
||||
return base.RunWithResponseFormatReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public override Task RunWithGenericTypeReturnsExpectedResultAsync() =>
|
||||
base.RunWithGenericTypeReturnsExpectedResultAsync();
|
||||
public override Task RunWithGenericTypeReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
|
||||
return base.RunWithGenericTypeReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() =>
|
||||
base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
|
||||
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
|
||||
return base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);CS8793</NoWarn>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -28,16 +28,24 @@ public class CopilotStudioFixture : IAgentFixture
|
||||
// Chat Completion does not require/support deleting threads, so this is a no-op.
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task InitializeAsync()
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
const string CopilotStudioHttpClientName = nameof(CopilotStudioAgent);
|
||||
|
||||
var settings = new CopilotStudioConnectionSettings(
|
||||
TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioTenantId),
|
||||
TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioAgentAppId))
|
||||
CopilotStudioConnectionSettings? settings = null;
|
||||
try
|
||||
{
|
||||
DirectConnectUrl = TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioDirectConnectUrl),
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
ServiceCollection services = new();
|
||||
|
||||
@@ -56,8 +64,12 @@ public class CopilotStudioFixture : IAgentFixture
|
||||
|
||||
this.Agent = new CopilotStudioAgent(client);
|
||||
|
||||
return Task.CompletedTask;
|
||||
return default;
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,23 +10,33 @@ public class CopilotStudioRunStreamingTests() : RunStreamingTests<CopilotStudioF
|
||||
// Set to null to run the tests.
|
||||
private const string ManualVerification = "For manual verification";
|
||||
|
||||
[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 SessionMaintainsHistoryAsync()
|
||||
{
|
||||
Assert.Skip("Copilot Studio does not support session history retrieval, so this test is not applicable.");
|
||||
return base.SessionMaintainsHistoryAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithChatMessageReturnsExpectedResultAsync() =>
|
||||
base.RunWithChatMessageReturnsExpectedResultAsync();
|
||||
public override Task RunWithChatMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
|
||||
return base.RunWithChatMessageReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithChatMessagesReturnsExpectedResultAsync() =>
|
||||
base.RunWithChatMessagesReturnsExpectedResultAsync();
|
||||
public override Task RunWithChatMessagesReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
|
||||
return base.RunWithChatMessagesReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync() =>
|
||||
base.RunWithNoMessageDoesNotFailAsync();
|
||||
public override Task RunWithNoMessageDoesNotFailAsync()
|
||||
{
|
||||
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
|
||||
return base.RunWithNoMessageDoesNotFailAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithStringReturnsExpectedResultAsync() =>
|
||||
base.RunWithStringReturnsExpectedResultAsync();
|
||||
public override Task RunWithStringReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
|
||||
return base.RunWithStringReturnsExpectedResultAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,23 +10,33 @@ public class CopilotStudioRunTests() : RunTests<CopilotStudioFixture>(() => new(
|
||||
// Set to null to run the tests.
|
||||
private const string ManualVerification = "For manual verification";
|
||||
|
||||
[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 SessionMaintainsHistoryAsync()
|
||||
{
|
||||
Assert.Skip("Copilot Studio does not support session history retrieval, so this test is not applicable.");
|
||||
return base.SessionMaintainsHistoryAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithChatMessageReturnsExpectedResultAsync() => base.RunWithChatMessageReturnsExpectedResultAsync();
|
||||
public override Task RunWithChatMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
|
||||
return base.RunWithChatMessageReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithChatMessagesReturnsExpectedResultAsync() =>
|
||||
public override Task RunWithChatMessagesReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
|
||||
return base.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();
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithStringReturnsExpectedResultAsync() =>
|
||||
base.RunWithStringReturnsExpectedResultAsync();
|
||||
public override Task RunWithStringReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
|
||||
return base.RunWithStringReturnsExpectedResultAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,25 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<IsAotCompatible>false</IsAotCompatible>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0;net472</TargetFrameworks>
|
||||
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
|
||||
<NoWarn>$(NoWarn);Moq1410;xUnit2023;MAAI001</NoWarn>
|
||||
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
|
||||
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
|
||||
<NoWarn>$(NoWarn);Moq1410;xUnit1051;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xRetry" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xRetry.v3" />
|
||||
<PackageReference Include="xunit.v3.mtp-v2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="xRetry" />
|
||||
<Using Include="xRetry.v3" />
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+36
-34
@@ -58,7 +58,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
private bool _preserveContainer;
|
||||
private CosmosClient? _setupClient; // Only used for test setup/cleanup
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
// Fail fast if emulator is not available
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -100,8 +100,10 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
if (this._setupClient != null && this._emulatorAvailable)
|
||||
{
|
||||
try
|
||||
@@ -143,12 +145,12 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
// Locally: Skip if emulator connection check failed
|
||||
var ciEmulatorAvailable = string.Equals(Environment.GetEnvironmentVariable("COSMOSDB_EMULATOR_AVAILABLE"), bool.TrueString, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Xunit.Skip.If(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available");
|
||||
Assert.SkipWhen(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available");
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void StateKeys_ReturnsDefaultKey_WhenNoStateKeyProvided()
|
||||
{
|
||||
@@ -163,7 +165,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Contains("CosmosChatHistoryProvider", provider.StateKeys);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void StateKeys_ReturnsCustomKey_WhenSetViaConstructor()
|
||||
{
|
||||
@@ -179,7 +181,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Contains("custom-key", provider.StateKeys);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithConnectionString_ShouldCreateInstance()
|
||||
{
|
||||
@@ -196,7 +198,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(TestContainerId, provider.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithNullConnectionString_ShouldThrowArgumentException()
|
||||
{
|
||||
@@ -206,7 +208,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
_ => new CosmosChatHistoryProvider.State("test-conversation")));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithNullStateInitializer_ShouldThrowArgumentNullException()
|
||||
{
|
||||
@@ -221,7 +223,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region InvokedAsync Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_WithSingleMessage_ShouldAddMessageAsync()
|
||||
{
|
||||
@@ -286,7 +288,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(ChatRole.User, messageList[0].Role);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_WithMultipleMessages_ShouldAddAllMessagesAsync()
|
||||
{
|
||||
@@ -329,7 +331,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region InvokingAsync Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokingAsync_WithNoMessages_ShouldReturnEmptyAsync()
|
||||
{
|
||||
@@ -347,7 +349,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Empty(messages);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokingAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync()
|
||||
{
|
||||
@@ -391,7 +393,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region Integration Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task FullWorkflow_AddAndGet_ShouldWorkCorrectlyAsync()
|
||||
{
|
||||
@@ -442,7 +444,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region Disposal Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Dispose_AfterUse_ShouldNotThrow()
|
||||
{
|
||||
@@ -455,7 +457,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
provider.Dispose(); // Should not throw
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Dispose_MultipleCalls_ShouldNotThrow()
|
||||
{
|
||||
@@ -473,7 +475,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region Hierarchical Partitioning Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalConnectionString_ShouldCreateInstance()
|
||||
{
|
||||
@@ -490,7 +492,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(HierarchicalTestContainerId, provider.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalEndpoint_ShouldCreateInstance()
|
||||
{
|
||||
@@ -508,7 +510,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(HierarchicalTestContainerId, provider.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalCosmosClient_ShouldCreateInstance()
|
||||
{
|
||||
@@ -525,7 +527,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(HierarchicalTestContainerId, provider.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void State_WithEmptyConversationId_ShouldThrowArgumentException()
|
||||
{
|
||||
@@ -534,7 +536,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
new CosmosChatHistoryProvider.State(""));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void State_WithWhitespaceConversationId_ShouldThrowArgumentException()
|
||||
{
|
||||
@@ -543,7 +545,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
new CosmosChatHistoryProvider.State(" "));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync()
|
||||
{
|
||||
@@ -597,7 +599,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(SessionId, (string)document!.sessionId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync()
|
||||
{
|
||||
@@ -636,7 +638,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Third hierarchical message", messageList[2].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokingAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync()
|
||||
{
|
||||
@@ -682,7 +684,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Message from user 2", messageList2[0].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task StateBag_WithHierarchicalPartitioning_ShouldPreserveStateAcrossProviderInstancesAsync()
|
||||
{
|
||||
@@ -717,7 +719,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(HierarchicalTestContainerId, newStore.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task HierarchicalAndSimplePartitioning_ShouldCoexistAsync()
|
||||
{
|
||||
@@ -759,7 +761,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Hierarchical partitioning message", hierarchicalMessageList[0].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task MaxMessagesToRetrieve_ShouldLimitAndReturnMostRecentAsync()
|
||||
{
|
||||
@@ -800,7 +802,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Message 10", messageList[4].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task MaxMessagesToRetrieve_Null_ShouldReturnAllMessagesAsync()
|
||||
{
|
||||
@@ -836,7 +838,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Message 10", messageList[9].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task GetMessageCountAsync_WithMessages_ShouldReturnCorrectCountAsync()
|
||||
{
|
||||
@@ -868,7 +870,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(5, count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task GetMessageCountAsync_WithNoMessages_ShouldReturnZeroAsync()
|
||||
{
|
||||
@@ -887,7 +889,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task ClearMessagesAsync_WithMessages_ShouldDeleteAndReturnCountAsync()
|
||||
{
|
||||
@@ -935,7 +937,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Empty(retrievedMessages);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task ClearMessagesAsync_WithNoMessages_ShouldReturnZeroAsync()
|
||||
{
|
||||
@@ -958,7 +960,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region Message Filter Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_DefaultFilter_ExcludesChatHistoryMessagesFromStorageAsync()
|
||||
{
|
||||
@@ -993,7 +995,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Response", messages[2].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_CustomStorageInputFilter_OverridesDefaultAsync()
|
||||
{
|
||||
@@ -1031,7 +1033,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Response", messages[1].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokingAsync_RetrievalOutputFilter_FiltersRetrievedMessagesAsync()
|
||||
{
|
||||
|
||||
+22
-20
@@ -55,7 +55,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
return options;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
// Fail fast if emulator is not available
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -88,8 +88,10 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
if (this._cosmosClient != null && this._emulatorAvailable)
|
||||
{
|
||||
try
|
||||
@@ -124,12 +126,12 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
// Locally: Skip if emulator connection check failed
|
||||
var ciEmulatorAvailable = string.Equals(Environment.GetEnvironmentVariable("COSMOSDB_EMULATOR_AVAILABLE"), bool.TrueString, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Xunit.Skip.If(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available");
|
||||
Assert.SkipWhen(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available");
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Constructor_WithCosmosClient_SetsProperties()
|
||||
{
|
||||
// Arrange
|
||||
@@ -143,7 +145,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(TestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Constructor_WithConnectionString_SetsProperties()
|
||||
{
|
||||
// Arrange
|
||||
@@ -157,7 +159,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(TestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Constructor_WithNullCosmosClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
@@ -165,7 +167,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
new CosmosCheckpointStore((CosmosClient)null!, s_testDatabaseId, TestContainerId));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Constructor_WithNullConnectionString_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
@@ -177,7 +179,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region Checkpoint Operations Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_NewCheckpoint_CreatesSuccessfullyAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -197,7 +199,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
Assert.NotEmpty(checkpointInfo.CheckpointId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task RetrieveCheckpointAsync_ExistingCheckpoint_ReturnsCorrectValueAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -218,7 +220,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Hello, World!", messageProp.GetString());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task RetrieveCheckpointAsync_NonExistentCheckpoint_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -233,7 +235,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
store.RetrieveCheckpointAsync(sessionId, fakeCheckpointInfo).AsTask());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task RetrieveIndexAsync_EmptyStore_ReturnsEmptyCollectionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -250,7 +252,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
Assert.Empty(index);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task RetrieveIndexAsync_WithCheckpoints_ReturnsAllCheckpointsAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -275,7 +277,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
Assert.Contains(index, c => c.CheckpointId == checkpoint3.CheckpointId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_WithParent_CreatesHierarchyAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -295,7 +297,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
Assert.Equal(sessionId, childCheckpoint.SessionId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task RetrieveIndexAsync_WithParentFilter_ReturnsFilteredResultsAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -331,7 +333,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region Run Isolation Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task CheckpointOperations_DifferentRuns_IsolatesDataAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -361,7 +363,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_WithNullSessionId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -375,7 +377,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
store.CreateCheckpointAsync(null!, checkpointValue).AsTask());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_WithEmptySessionId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -389,7 +391,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
store.CreateCheckpointAsync("", checkpointValue).AsTask());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task RetrieveCheckpointAsync_WithNullCheckpointInfo_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -407,7 +409,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
|
||||
#region Disposal Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public async Task Dispose_AfterDisposal_ThrowsObjectDisposedExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
@@ -424,7 +426,7 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
store.CreateCheckpointAsync("test-run", checkpointValue).AsTask());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Dispose_MultipleCalls_DoesNotThrow()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
-1
@@ -17,7 +17,6 @@
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos" />
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -9,7 +9,6 @@ using Microsoft.DurableTask.Client.Entities;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI.Chat;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
|
||||
+38
-4
@@ -6,7 +6,6 @@ using System.Reflection;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
@@ -30,7 +29,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
||||
|
||||
private readonly ITestOutputHelper _outputHelper = outputHelper;
|
||||
|
||||
async Task IAsyncLifetime.InitializeAsync()
|
||||
async ValueTask IAsyncLifetime.InitializeAsync()
|
||||
{
|
||||
if (!s_infrastructureStarted)
|
||||
{
|
||||
@@ -39,7 +38,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
||||
}
|
||||
}
|
||||
|
||||
async Task IAsyncLifetime.DisposeAsync()
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
// Nothing to clean up
|
||||
await Task.CompletedTask;
|
||||
@@ -736,6 +735,9 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
||||
|
||||
private async Task RunSampleTestAsync(string samplePath, Func<Process, BlockingCollection<OutputLog>, Task> testAction)
|
||||
{
|
||||
// Build the sample project first (it may not have been built as part of the solution)
|
||||
await this.BuildSampleAsync(samplePath);
|
||||
|
||||
// Generate a unique TaskHub name for this sample test to prevent cross-test interference
|
||||
// when multiple tests run together and share the same DTS emulator.
|
||||
string uniqueTaskHubName = $"sample-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
|
||||
@@ -814,12 +816,44 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task BuildSampleAsync(string samplePath)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Building sample at {samplePath}...");
|
||||
|
||||
ProcessStartInfo buildInfo = new()
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"build --framework {s_dotnetTargetFramework}",
|
||||
WorkingDirectory = samplePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
|
||||
using Process buildProcess = new() { StartInfo = buildInfo };
|
||||
buildProcess.Start();
|
||||
|
||||
// Read both streams asynchronously to avoid deadlocks from filled pipe buffers
|
||||
Task<string> stdoutTask = buildProcess.StandardOutput.ReadToEndAsync();
|
||||
Task<string> stderrTask = buildProcess.StandardError.ReadToEndAsync();
|
||||
await buildProcess.WaitForExitAsync();
|
||||
|
||||
string stderr = await stderrTask;
|
||||
if (buildProcess.ExitCode != 0)
|
||||
{
|
||||
string stdout = await stdoutTask;
|
||||
throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}");
|
||||
}
|
||||
|
||||
this._outputHelper.WriteLine($"Build completed for {samplePath}.");
|
||||
}
|
||||
|
||||
private Process StartConsoleApp(string samplePath, BlockingCollection<OutputLog> logs, string taskHubName)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run --framework {s_dotnetTargetFramework}",
|
||||
Arguments = $"run --no-build --framework {s_dotnetTargetFramework}",
|
||||
WorkingDirectory = samplePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
|
||||
@@ -9,7 +9,6 @@ using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI.Chat;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
|
||||
|
||||
|
||||
-1
@@ -2,7 +2,6 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
|
||||
|
||||
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Public packages required by integration tests -->
|
||||
|
||||
@@ -7,7 +7,6 @@ using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI.Chat;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
@@ -14,7 +13,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
using Xunit.Abstractions;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
@@ -166,7 +165,7 @@ internal sealed class TestHelper : IDisposable
|
||||
|
||||
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), new AzureCliCredential());
|
||||
: new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
|
||||
return client.GetChatClient(azureOpenAiDeploymentName);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.Entities;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI.Chat;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
@@ -41,7 +40,7 @@ public sealed class FoundryMemoryProviderTests : IDisposable
|
||||
if (!string.IsNullOrWhiteSpace(endpoint) &&
|
||||
!string.IsNullOrWhiteSpace(memoryStoreName))
|
||||
{
|
||||
this._client = new AIProjectClient(new Uri(endpoint), new AzureCliCredential());
|
||||
this._client = new AIProjectClient(new Uri(endpoint), TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
this._memoryStoreName = memoryStoreName;
|
||||
this._deploymentName = deploymentName ?? "gpt-4.1-mini";
|
||||
}
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -221,4 +221,26 @@ public sealed class GitHubCopilotAgentTests
|
||||
Assert.Null(result.ConfigDir);
|
||||
Assert.True(result.Streaming);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_AssistantMessageEvent_DoesNotEmitTextContent()
|
||||
{
|
||||
var assistantMessage = new AssistantMessageEvent
|
||||
{
|
||||
Data = new AssistantMessageData
|
||||
{
|
||||
MessageId = "msg-456",
|
||||
Content = "Some streamed content that was already delivered via delta events"
|
||||
}
|
||||
};
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
const string TestId = "agent-id";
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null);
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage);
|
||||
|
||||
// result.Text need to be empty because the content was already delivered via delta events, and we want to avoid emitting duplicate content in the response update.
|
||||
// The content should be delivered through TextContent in the Contents collection instead.
|
||||
Assert.Empty(result.Text);
|
||||
Assert.DoesNotContain(result.Contents, c => c is TextContent);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -16,7 +16,6 @@ using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests;
|
||||
|
||||
|
||||
+38
-4
@@ -8,7 +8,6 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests;
|
||||
|
||||
@@ -36,7 +35,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
|
||||
private readonly ITestOutputHelper _outputHelper = outputHelper;
|
||||
|
||||
async Task IAsyncLifetime.InitializeAsync()
|
||||
async ValueTask IAsyncLifetime.InitializeAsync()
|
||||
{
|
||||
if (!s_infrastructureStarted)
|
||||
{
|
||||
@@ -45,7 +44,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
}
|
||||
}
|
||||
|
||||
async Task IAsyncLifetime.DisposeAsync()
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
// Nothing to clean up
|
||||
await Task.CompletedTask;
|
||||
@@ -793,6 +792,9 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
|
||||
private async Task RunSampleTestAsync(string samplePath, Func<IReadOnlyList<OutputLog>, Task> testAction)
|
||||
{
|
||||
// Build the sample project first (it may not have been built as part of the solution)
|
||||
await this.BuildSampleAsync(samplePath);
|
||||
|
||||
// Start the Azure Functions app
|
||||
List<OutputLog> logsContainer = [];
|
||||
using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer);
|
||||
@@ -812,12 +814,44 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
|
||||
private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
|
||||
|
||||
private async Task BuildSampleAsync(string samplePath)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Building sample at {samplePath}...");
|
||||
|
||||
ProcessStartInfo buildInfo = new()
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"build -f {s_dotnetTargetFramework}",
|
||||
WorkingDirectory = samplePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
|
||||
using Process buildProcess = new() { StartInfo = buildInfo };
|
||||
buildProcess.Start();
|
||||
|
||||
// Read both streams asynchronously to avoid deadlocks from filled pipe buffers
|
||||
Task<string> stdoutTask = buildProcess.StandardOutput.ReadToEndAsync();
|
||||
Task<string> stderrTask = buildProcess.StandardError.ReadToEndAsync();
|
||||
await buildProcess.WaitForExitAsync();
|
||||
|
||||
string stderr = await stderrTask;
|
||||
if (buildProcess.ExitCode != 0)
|
||||
{
|
||||
string stdout = await stdoutTask;
|
||||
throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}");
|
||||
}
|
||||
|
||||
this._outputHelper.WriteLine($"Build completed for {samplePath}.");
|
||||
}
|
||||
|
||||
private Process StartFunctionApp(string samplePath, List<OutputLog> logs)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}",
|
||||
Arguments = $"run --no-build -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}",
|
||||
WorkingDirectory = samplePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="CompactingChatClient"/> class.
|
||||
/// </summary>
|
||||
public sealed class CompactingChatClientTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Restores the static <see cref="AIAgent.CurrentRunContext"/> after each test.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
SetCurrentRunContext(null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorThrowsOnNullStrategyAsync()
|
||||
{
|
||||
Mock<IChatClient> mockInner = new();
|
||||
Assert.Throws<ArgumentNullException>(() => new CompactingChatClient(mockInner.Object, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncNoContextPassesThroughAsync()
|
||||
{
|
||||
// Arrange — no CurrentRunContext set → passthrough
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatResponse response = await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expectedResponse, response);
|
||||
mockInner.Verify(c => c.GetResponseAsync(
|
||||
messages,
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncWithContextAppliesCompactionAsync()
|
||||
{
|
||||
// Arrange — set CurrentRunContext so compaction runs
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Done")]);
|
||||
List<ChatMessage>? capturedMessages = null;
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
capturedMessages = [.. msgs])
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
// Strategy that always triggers and keeps only 1 group
|
||||
TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatResponse response = await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert — compaction should have removed oldest groups
|
||||
Assert.Same(expectedResponse, response);
|
||||
Assert.NotNull(capturedMessages);
|
||||
Assert.True(capturedMessages!.Count < messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncNoCompactionNeededReturnsOriginalMessagesAsync()
|
||||
{
|
||||
// Arrange — trigger never fires → no compaction
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
|
||||
List<ChatMessage>? capturedMessages = null;
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
capturedMessages = [.. msgs])
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
];
|
||||
|
||||
// Act
|
||||
await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert — original messages passed through
|
||||
Assert.NotNull(capturedMessages);
|
||||
Assert.Single(capturedMessages!);
|
||||
Assert.Equal("Hello", capturedMessages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncWithExistingIndexUpdatesAsync()
|
||||
{
|
||||
// Arrange — call twice to exercise the "existing index" path (state.MessageIndex.Count > 0)
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "OK")]));
|
||||
|
||||
// Strategy that always triggers, keeping 1 group
|
||||
TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
List<ChatMessage> messages1 =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
// First call — initializes state
|
||||
await client.GetResponseAsync(messages1);
|
||||
|
||||
List<ChatMessage> messages2 =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
];
|
||||
|
||||
// Act — second call exercises the update path
|
||||
ChatResponse response = await client.GetResponseAsync(messages2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncNullSessionReturnsOriginalAsync()
|
||||
{
|
||||
// Arrange — CurrentRunContext exists but Session is null
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
// Set context with null session
|
||||
SetRunContext(null);
|
||||
|
||||
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
ChatResponse response = await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expectedResponse, response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsyncNoContextPassesThroughAsync()
|
||||
{
|
||||
// Arrange — no CurrentRunContext
|
||||
Mock<IChatClient> mockInner = new();
|
||||
ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Hi")];
|
||||
mockInner.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(updates));
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> results = [];
|
||||
await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages))
|
||||
{
|
||||
results.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("Hi", results[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsyncWithContextAppliesCompactionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockInner = new();
|
||||
ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Done")];
|
||||
mockInner.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(updates));
|
||||
|
||||
TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> results = [];
|
||||
await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages))
|
||||
{
|
||||
results.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("Done", results[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceReturnsStrategyForMatchingType()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockInner = new();
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
// Act — typeof(Type).IsInstanceOfType(typeof(CompactionStrategy)) is true
|
||||
object? result = client.GetService(typeof(Type));
|
||||
|
||||
// Assert
|
||||
Assert.Same(strategy, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceDelegatesToBaseForNonMatchingType()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockInner = new();
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
// Act — typeof(string) doesn't match
|
||||
object? result = client.GetService(typeof(string));
|
||||
|
||||
// Assert — delegates to base (which returns null for unregistered types)
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceThrowsOnNullType()
|
||||
{
|
||||
Mock<IChatClient> mockInner = new();
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => client.GetService(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceWithServiceKeyDelegatesToBase()
|
||||
{
|
||||
// Arrange — non-null serviceKey always delegates
|
||||
Mock<IChatClient> mockInner = new();
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
// Act
|
||||
object? result = client.GetService(typeof(Type), serviceKey: "mykey");
|
||||
|
||||
// Assert — delegates to base because serviceKey is non-null
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncMessagesNotListCreatesListCopyAsync()
|
||||
{
|
||||
// Arrange — pass IEnumerable (not List<ChatMessage>) to exercise the list copy branch
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
// Use an IEnumerable (not a List) to trigger the copy path
|
||||
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
ChatResponse response = await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expectedResponse, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets <see cref="AIAgent.CurrentRunContext"/> via reflection.
|
||||
/// </summary>
|
||||
private static void SetCurrentRunContext(AgentRunContext? context)
|
||||
{
|
||||
FieldInfo? field = typeof(AIAgent).GetField("s_currentContext", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
object? asyncLocal = field!.GetValue(null);
|
||||
Assert.NotNull(asyncLocal);
|
||||
PropertyInfo? valueProp = asyncLocal!.GetType().GetProperty("Value");
|
||||
Assert.NotNull(valueProp);
|
||||
valueProp!.SetValue(asyncLocal, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="AgentRunContext"/> with the given session and sets it as the current context.
|
||||
/// </summary>
|
||||
private static void SetRunContext(AgentSession? session)
|
||||
{
|
||||
Mock<AIAgent> mockAgent = new() { CallBase = true };
|
||||
AgentRunContext context = new(
|
||||
mockAgent.Object,
|
||||
session,
|
||||
[new(ChatRole.User, "test")],
|
||||
null);
|
||||
SetCurrentRunContext(context);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(
|
||||
ChatResponseUpdate[] updates, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (ChatResponseUpdate update in updates)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return update;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="CompactionStrategy"/> abstract base class.
|
||||
/// </summary>
|
||||
public class CompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructorNullTriggerThrows()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new TestStrategy(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger never fires
|
||||
TestStrategy strategy = new(_ => false);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerMetCallsApplyAsync()
|
||||
{
|
||||
// Arrange — trigger always fires
|
||||
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger fires but Apply does nothing
|
||||
TestStrategy strategy = new(_ => true, applyFunc: _ => false);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync()
|
||||
{
|
||||
// Arrange — trigger fires when groups > 2
|
||||
// Default target should be: stop when groups <= 2 (i.e., !trigger)
|
||||
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
|
||||
TestStrategy strategy = new(trigger, applyFunc: index =>
|
||||
{
|
||||
// Exclude oldest non-system group one at a time
|
||||
foreach (MessageGroup group in index.Groups)
|
||||
{
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
group.IsExcluded = true;
|
||||
// Target (default = !trigger) returns true when groups <= 2
|
||||
// So the strategy would check Target after this exclusion
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — trigger fires (4 > 2), Apply is called
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync()
|
||||
{
|
||||
// Arrange — custom target that always signals stop
|
||||
bool targetCalled = false;
|
||||
CompactionTrigger customTarget = _ =>
|
||||
{
|
||||
targetCalled = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
TestStrategy strategy = new(_ => true, customTarget, _ =>
|
||||
{
|
||||
// Access the target from within the strategy
|
||||
return true;
|
||||
});
|
||||
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — the custom target is accessible (verified by TestStrategy checking it)
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
// The target is accessible to derived classes via the protected property
|
||||
Assert.True(strategy.InvokeTarget(index));
|
||||
Assert.True(targetCalled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A concrete test implementation of <see cref="CompactionStrategy"/> for testing the base class.
|
||||
/// </summary>
|
||||
private sealed class TestStrategy : CompactionStrategy
|
||||
{
|
||||
private readonly Func<MessageIndex, bool>? _applyFunc;
|
||||
|
||||
public TestStrategy(
|
||||
CompactionTrigger trigger,
|
||||
CompactionTrigger? target = null,
|
||||
Func<MessageIndex, bool>? applyFunc = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this._applyFunc = applyFunc;
|
||||
}
|
||||
|
||||
public int ApplyCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the protected Target property for test verification.
|
||||
/// </summary>
|
||||
public bool InvokeTarget(MessageIndex index) => this.Target(index);
|
||||
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
this.ApplyCallCount++;
|
||||
bool result = this._applyFunc?.Invoke(index) ?? false;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="CompactionTrigger"/> and <see cref="CompactionTriggers"/>.
|
||||
/// </summary>
|
||||
public class CompactionTriggersTests
|
||||
{
|
||||
[Fact]
|
||||
public void TokensExceedReturnsTrueWhenAboveThreshold()
|
||||
{
|
||||
// Arrange — use a long message to guarantee tokens > 0
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensExceedReturnsFalseWhenBelowThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessagesExceedReturnsExpectedResult()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
|
||||
MessageIndex small = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.User, "B"),
|
||||
]);
|
||||
MessageIndex large = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.User, "B"),
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
]);
|
||||
|
||||
Assert.False(trigger(small));
|
||||
Assert.True(trigger(large));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TurnsExceedReturnsExpectedResult()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1);
|
||||
MessageIndex oneTurn = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
]);
|
||||
MessageIndex twoTurns = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
Assert.False(trigger(oneTurn));
|
||||
Assert.True(trigger(twoTurns));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupsExceedReturnsExpectedResult()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.Assistant, "B"),
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
]);
|
||||
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasToolCallsReturnsTrueWhenToolCallGroupExists()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
]);
|
||||
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasToolCallsReturnsFalseWhenNoToolCallGroup()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllRequiresAllConditions()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.All(
|
||||
CompactionTriggers.TokensExceed(0),
|
||||
CompactionTriggers.MessagesExceed(5));
|
||||
|
||||
MessageIndex small = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
|
||||
// Tokens > 0 is true, but messages > 5 is false
|
||||
Assert.False(trigger(small));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyRequiresAtLeastOneCondition()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.Any(
|
||||
CompactionTriggers.TokensExceed(999_999),
|
||||
CompactionTriggers.MessagesExceed(0));
|
||||
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
|
||||
// Tokens not exceeded, but messages > 0 is true
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEmptyTriggersReturnsTrue()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.All();
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyEmptyTriggersReturnsFalse()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.Any();
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensBelowReturnsTrueWhenBelowThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensBelowReturnsFalseWhenAboveThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensBelow(0);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlwaysReturnsTrue()
|
||||
{
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.True(CompactionTriggers.Always(index));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="MessageIndex"/> class.
|
||||
/// </summary>
|
||||
public class MessageIndexTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateEmptyListReturnsEmptyGroups()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages = [];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(groups.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSystemMessageCreatesSystemGroup()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
|
||||
Assert.Single(groups.Groups[0].Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUserMessageCreatesUserGroup()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.User, groups.Groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAssistantTextMessageCreatesAssistantTextGroup()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "Hi there!"),
|
||||
];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateToolCallWithResultsCreatesAtomicToolCallGroup()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "Seattle" })]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny, 72°F");
|
||||
|
||||
List<ChatMessage> messages = [assistantMessage, toolResult];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
|
||||
Assert.Equal(2, groups.Groups[0].Messages.Count);
|
||||
Assert.Same(assistantMessage, groups.Groups[0].Messages[0]);
|
||||
Assert.Same(toolResult, groups.Groups[0].Messages[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMixedConversationGroupsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
|
||||
ChatMessage userMsg = new(ChatRole.User, "What's the weather?");
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
ChatMessage assistantText = new(ChatRole.Assistant, "The weather is sunny!");
|
||||
|
||||
List<ChatMessage> messages = [systemMsg, userMsg, assistantToolCall, toolResult, assistantText];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, groups.Groups.Count);
|
||||
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.User, groups.Groups[1].Kind);
|
||||
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[2].Kind);
|
||||
Assert.Equal(2, groups.Groups[2].Messages.Count);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[3].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMultipleToolResultsGroupsAllWithAssistant()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [
|
||||
new FunctionCallContent("call1", "get_weather"),
|
||||
new FunctionCallContent("call2", "get_time"),
|
||||
]);
|
||||
ChatMessage toolResult1 = new(ChatRole.Tool, "Sunny");
|
||||
ChatMessage toolResult2 = new(ChatRole.Tool, "3:00 PM");
|
||||
|
||||
List<ChatMessage> messages = [assistantToolCall, toolResult1, toolResult2];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
|
||||
Assert.Equal(3, groups.Groups[0].Messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedMessagesExcludesMarkedGroups()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage msg1 = new(ChatRole.User, "First");
|
||||
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
|
||||
ChatMessage msg3 = new(ChatRole.User, "Second");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3]);
|
||||
groups.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, included.Count);
|
||||
Assert.Same(msg1, included[0]);
|
||||
Assert.Same(msg3, included[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllMessagesIncludesExcludedGroups()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage msg1 = new(ChatRole.User, "First");
|
||||
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([msg1, msg2]);
|
||||
groups.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
List<ChatMessage> all = [.. groups.GetAllMessages()];
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, all.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncludedGroupCountReflectsExclusions()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.Assistant, "B"),
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
]);
|
||||
|
||||
groups.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
Assert.Equal(2, groups.IncludedMessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSummaryMessageCreatesSummaryGroup()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts...");
|
||||
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
|
||||
|
||||
List<ChatMessage> messages = [summaryMessage];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.Summary, groups.Groups[0].Kind);
|
||||
Assert.Same(summaryMessage, groups.Groups[0].Messages[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSummaryAmongOtherMessagesGroupsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
|
||||
ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]: previous context");
|
||||
(summaryMsg.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
|
||||
ChatMessage userMsg = new(ChatRole.User, "Continue...");
|
||||
|
||||
List<ChatMessage> messages = [systemMsg, summaryMsg, userMsg];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, groups.Groups.Count);
|
||||
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.Summary, groups.Groups[1].Kind);
|
||||
Assert.Equal(MessageGroupKind.User, groups.Groups[2].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessageGroupStoresPassedCounts()
|
||||
{
|
||||
// Arrange & Act
|
||||
MessageGroup group = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, group.MessageCount);
|
||||
Assert.Equal(5, group.ByteCount);
|
||||
Assert.Equal(2, group.TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessageGroupMessagesAreImmutable()
|
||||
{
|
||||
// Arrange
|
||||
IReadOnlyList<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
MessageGroup group = new(MessageGroupKind.User, messages, byteCount: 5, tokenCount: 1);
|
||||
|
||||
// Assert — Messages is IReadOnlyList, not IList
|
||||
Assert.IsAssignableFrom<IReadOnlyList<ChatMessage>>(group.Messages);
|
||||
Assert.Same(messages, group.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateComputesByteCountUtf8()
|
||||
{
|
||||
// Arrange — "Hello" is 5 UTF-8 bytes
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, groups.Groups[0].ByteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateComputesByteCountMultiByteChars()
|
||||
{
|
||||
// Arrange — "café" has a multi-byte 'é' (2 bytes in UTF-8) → 5 bytes total
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "café")]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, groups.Groups[0].ByteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateComputesByteCountMultipleMessagesInGroup()
|
||||
{
|
||||
// Arrange — ToolCall group: assistant (tool call, null text) + tool result "OK" (2 bytes)
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "OK");
|
||||
MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]);
|
||||
|
||||
// Assert — single ToolCall group with 2 messages
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(2, groups.Groups[0].MessageCount);
|
||||
Assert.Equal(2, groups.Groups[0].ByteCount); // "OK" = 2 bytes, assistant text is null
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDefaultTokenCountIsHeuristic()
|
||||
{
|
||||
// Arrange — "Hello world test data!" = 22 UTF-8 bytes → 22 / 4 = 5 estimated tokens
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(22, groups.Groups[0].ByteCount);
|
||||
Assert.Equal(22 / 4, groups.Groups[0].TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateNullTextHasZeroCounts()
|
||||
{
|
||||
// Arrange — message with no text (e.g., pure function call)
|
||||
ChatMessage msg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage tool = new(ChatRole.Tool, string.Empty);
|
||||
MessageIndex groups = MessageIndex.Create([msg, tool]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, groups.Groups[0].MessageCount);
|
||||
Assert.Equal(0, groups.Groups[0].ByteCount);
|
||||
Assert.Equal(0, groups.Groups[0].TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalAggregatesSumAllGroups()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
|
||||
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
|
||||
]);
|
||||
|
||||
groups.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act & Assert — totals include excluded groups
|
||||
Assert.Equal(2, groups.TotalGroupCount);
|
||||
Assert.Equal(2, groups.TotalMessageCount);
|
||||
Assert.Equal(8, groups.TotalByteCount);
|
||||
Assert.Equal(2, groups.TotalTokenCount); // Each group: 4 bytes / 4 = 1 token, 2 groups = 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncludedAggregatesExcludeMarkedGroups()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
|
||||
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
|
||||
new ChatMessage(ChatRole.User, "CCCC"), // 4 bytes
|
||||
]);
|
||||
|
||||
groups.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(3, groups.TotalGroupCount);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
Assert.Equal(3, groups.TotalMessageCount);
|
||||
Assert.Equal(2, groups.IncludedMessageCount);
|
||||
Assert.Equal(12, groups.TotalByteCount);
|
||||
Assert.Equal(8, groups.IncludedByteCount);
|
||||
Assert.Equal(3, groups.TotalTokenCount); // 12 / 4 = 3 (across 3 groups of 4 bytes each = 1+1+1)
|
||||
Assert.Equal(2, groups.IncludedTokenCount); // 8 / 4 = 2 (2 included groups of 4 bytes = 1+1)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolCallGroupAggregatesAcrossMessages()
|
||||
{
|
||||
// Arrange — tool call group with assistant "Ask" (3 bytes) + tool result "OK" (2 bytes)
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "OK");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]);
|
||||
|
||||
// Assert — single group with 2 messages
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(2, groups.Groups[0].MessageCount);
|
||||
Assert.Equal(2, groups.Groups[0].ByteCount); // assistant text is null (function call), tool result is "OK" = 2 bytes
|
||||
Assert.Equal(1, groups.TotalGroupCount);
|
||||
Assert.Equal(2, groups.TotalMessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAssignsTurnIndicesSingleTurn()
|
||||
{
|
||||
// Arrange — System (no turn), User + Assistant = turn 1
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
Assert.Null(groups.Groups[0].TurnIndex); // System
|
||||
Assert.Equal(1, groups.Groups[1].TurnIndex); // User
|
||||
Assert.Equal(1, groups.Groups[2].TurnIndex); // Assistant
|
||||
Assert.Equal(1, groups.TotalTurnCount);
|
||||
Assert.Equal(1, groups.IncludedTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAssignsTurnIndicesMultiTurn()
|
||||
{
|
||||
// Arrange — 3 user turns
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Assert — 6 groups: System(null), User(1), Assistant(1), User(2), Assistant(2), User(3)
|
||||
Assert.Null(groups.Groups[0].TurnIndex);
|
||||
Assert.Equal(1, groups.Groups[1].TurnIndex);
|
||||
Assert.Equal(1, groups.Groups[2].TurnIndex);
|
||||
Assert.Equal(2, groups.Groups[3].TurnIndex);
|
||||
Assert.Equal(2, groups.Groups[4].TurnIndex);
|
||||
Assert.Equal(3, groups.Groups[5].TurnIndex);
|
||||
Assert.Equal(3, groups.TotalTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTurnSpansToolCallGroups()
|
||||
{
|
||||
// Arrange — turn 1 includes User, ToolCall, AssistantText
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "What's the weather?"),
|
||||
assistantToolCall,
|
||||
toolResult,
|
||||
new ChatMessage(ChatRole.Assistant, "The weather is sunny!"),
|
||||
]);
|
||||
|
||||
// Assert — all 3 groups belong to turn 1
|
||||
Assert.Equal(3, groups.Groups.Count);
|
||||
Assert.Equal(1, groups.Groups[0].TurnIndex); // User
|
||||
Assert.Equal(1, groups.Groups[1].TurnIndex); // ToolCall
|
||||
Assert.Equal(1, groups.Groups[2].TurnIndex); // AssistantText
|
||||
Assert.Equal(1, groups.TotalTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTurnGroupsReturnsGroupsForSpecificTurn()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
List<MessageGroup> turn1 = [.. groups.GetTurnGroups(1)];
|
||||
List<MessageGroup> turn2 = [.. groups.GetTurnGroups(2)];
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, turn1.Count);
|
||||
Assert.Equal(MessageGroupKind.User, turn1[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, turn1[1].Kind);
|
||||
Assert.Equal(2, turn2.Count);
|
||||
Assert.Equal(MessageGroupKind.User, turn2[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, turn2[1].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncludedTurnCountReflectsExclusions()
|
||||
{
|
||||
// Arrange — 2 turns, exclude all groups in turn 1
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
groups.Groups[0].IsExcluded = true; // User Q1 (turn 1)
|
||||
groups.Groups[1].IsExcluded = true; // Assistant A1 (turn 1)
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, groups.TotalTurnCount);
|
||||
Assert.Equal(1, groups.IncludedTurnCount); // Only turn 2 has included groups
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalTurnCountZeroWhenNoUserMessages()
|
||||
{
|
||||
// Arrange — only system messages
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System."),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, groups.TotalTurnCount);
|
||||
Assert.Equal(0, groups.IncludedTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncludedTurnCountPartialExclusionStillCountsTurn()
|
||||
{
|
||||
// Arrange — turn 1 has 2 groups, only one excluded
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
]);
|
||||
|
||||
groups.Groups[1].IsExcluded = true; // Exclude assistant but user is still included
|
||||
|
||||
// Assert — turn 1 still has one included group
|
||||
Assert.Equal(1, groups.TotalTurnCount);
|
||||
Assert.Equal(1, groups.IncludedTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAppendsNewMessagesIncrementally()
|
||||
{
|
||||
// Arrange — create with 2 messages
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
];
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
Assert.Equal(2, index.Groups.Count);
|
||||
Assert.Equal(2, index.ProcessedMessageCount);
|
||||
|
||||
// Act — add 2 more messages and update
|
||||
messages.Add(new ChatMessage(ChatRole.User, "Q2"));
|
||||
messages.Add(new ChatMessage(ChatRole.Assistant, "A2"));
|
||||
index.Update(messages);
|
||||
|
||||
// Assert — should have 4 groups total, processed count updated
|
||||
Assert.Equal(4, index.Groups.Count);
|
||||
Assert.Equal(4, index.ProcessedMessageCount);
|
||||
Assert.Equal(MessageGroupKind.User, index.Groups[2].Kind);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, index.Groups[3].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateNoOpWhenNoNewMessages()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
];
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
int originalCount = index.Groups.Count;
|
||||
|
||||
// Act — update with same count
|
||||
index.Update(messages);
|
||||
|
||||
// Assert — nothing changed
|
||||
Assert.Equal(originalCount, index.Groups.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRebuildsWhenMessagesShrink()
|
||||
{
|
||||
// Arrange — create with 3 messages
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
Assert.Equal(3, index.Groups.Count);
|
||||
|
||||
// Exclude a group to verify rebuild clears state
|
||||
index.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act — update with fewer messages (simulates storage compaction)
|
||||
List<ChatMessage> shortened =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
index.Update(shortened);
|
||||
|
||||
// Assert — rebuilt from scratch
|
||||
Assert.Single(index.Groups);
|
||||
Assert.False(index.Groups[0].IsExcluded);
|
||||
Assert.Equal(1, index.ProcessedMessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePreservesExistingGroupExclusionState()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
];
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
index.Groups[0].IsExcluded = true;
|
||||
index.Groups[0].ExcludeReason = "Test exclusion";
|
||||
|
||||
// Act — append new messages
|
||||
messages.Add(new ChatMessage(ChatRole.User, "Q2"));
|
||||
index.Update(messages);
|
||||
|
||||
// Assert — original exclusion state preserved
|
||||
Assert.True(index.Groups[0].IsExcluded);
|
||||
Assert.Equal("Test exclusion", index.Groups[0].ExcludeReason);
|
||||
Assert.Equal(3, index.Groups.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertGroupInsertsAtSpecifiedIndex()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act — insert between Q1 and Q2
|
||||
ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]");
|
||||
MessageGroup inserted = index.InsertGroup(1, MessageGroupKind.Summary, [summaryMsg], turnIndex: 1);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index.Groups.Count);
|
||||
Assert.Same(inserted, index.Groups[1]);
|
||||
Assert.Equal(MessageGroupKind.Summary, index.Groups[1].Kind);
|
||||
Assert.Equal("[Summary]", index.Groups[1].Messages[0].Text);
|
||||
Assert.Equal(1, inserted.TurnIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddGroupAppendsToEnd()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
ChatMessage msg = new(ChatRole.Assistant, "Appended");
|
||||
MessageGroup added = index.AddGroup(MessageGroupKind.AssistantText, [msg], turnIndex: 1);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, index.Groups.Count);
|
||||
Assert.Same(added, index.Groups[1]);
|
||||
Assert.Equal("Appended", index.Groups[1].Messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertGroupComputesByteAndTokenCounts()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
]);
|
||||
|
||||
// Act — insert a group with known text
|
||||
ChatMessage msg = new(ChatRole.Assistant, "Hello"); // 5 bytes, ~1 token (5/4)
|
||||
MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, inserted.ByteCount);
|
||||
Assert.Equal(1, inserted.TokenCount); // 5 / 4 = 1 (integer division)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithGroupsRestoresTurnIndex()
|
||||
{
|
||||
// Arrange — pre-existing groups with turn indices
|
||||
MessageGroup group1 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
|
||||
MessageGroup group2 = new(MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
|
||||
MessageGroup group3 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q2")], 2, 1, turnIndex: 2);
|
||||
List<MessageGroup> groups = [group1, group2, group3];
|
||||
|
||||
// Act — constructor should restore _currentTurn from the last group's TurnIndex
|
||||
MessageIndex index = new(groups);
|
||||
|
||||
// Assert — adding a new user message should get turn 3 (restored 2 + 1)
|
||||
index.Update(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// The new user group should have TurnIndex 3
|
||||
MessageGroup lastGroup = index.Groups[index.Groups.Count - 1];
|
||||
Assert.Equal(MessageGroupKind.User, lastGroup.Kind);
|
||||
Assert.NotNull(lastGroup.TurnIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithEmptyGroupsHandlesGracefully()
|
||||
{
|
||||
// Arrange & Act — constructor with empty list
|
||||
MessageIndex index = new([]);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(index.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithGroupsWithoutTurnIndexSkipsRestore()
|
||||
{
|
||||
// Arrange — groups without turn indices (system messages)
|
||||
MessageGroup systemGroup = new(MessageGroupKind.System, [new ChatMessage(ChatRole.System, "Be helpful")], 10, 3, turnIndex: null);
|
||||
List<MessageGroup> groups = [systemGroup];
|
||||
|
||||
// Act — constructor won't find a TurnIndex to restore
|
||||
MessageIndex index = new(groups);
|
||||
|
||||
// Assert
|
||||
Assert.Single(index.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTokenCountReturnsTokenCount()
|
||||
{
|
||||
// Arrange — call the public static method directly
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello world"),
|
||||
new ChatMessage(ChatRole.Assistant, "Greetings"),
|
||||
];
|
||||
|
||||
// Act — use a simple tokenizer that counts words (each word = 1 token)
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
|
||||
|
||||
// Assert — "Hello world" = 2, "Greetings" = 1 → 3 total
|
||||
Assert.Equal(3, tokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTokenCountEmptyTextReturnsZero()
|
||||
{
|
||||
// Arrange — message with no text content
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, [new FunctionCallContent("c1", "fn")]),
|
||||
];
|
||||
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
|
||||
|
||||
// Assert — no text content → 0 tokens
|
||||
Assert.Equal(0, tokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWithTokenizerUsesTokenizerForCounts()
|
||||
{
|
||||
// Arrange
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello world test"),
|
||||
];
|
||||
|
||||
// Act
|
||||
MessageIndex index = MessageIndex.Create(messages, tokenizer);
|
||||
|
||||
// Assert — tokenizer counts words: "Hello world test" = 3 tokens
|
||||
Assert.Single(index.Groups);
|
||||
Assert.Equal(3, index.Groups[0].TokenCount);
|
||||
Assert.NotNull(index.Tokenizer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertGroupWithTokenizerUsesTokenizer()
|
||||
{
|
||||
// Arrange
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
], tokenizer);
|
||||
|
||||
// Act
|
||||
ChatMessage msg = new(ChatRole.Assistant, "Hello world test message");
|
||||
MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]);
|
||||
|
||||
// Assert — tokenizer counts words: "Hello world test message" = 4 tokens
|
||||
Assert.Equal(4, inserted.TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWithStandaloneToolMessageGroupsAsAssistantText()
|
||||
{
|
||||
// A Tool message not preceded by an assistant tool-call falls through to the else branch
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Tool, "Orphaned tool result"),
|
||||
];
|
||||
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
|
||||
// The Tool message should be grouped as AssistantText (the default fallback)
|
||||
Assert.Single(index.Groups);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWithAssistantNonSummaryWithPropertiesFallsToAssistantText()
|
||||
{
|
||||
// Assistant message with AdditionalProperties but NOT a summary
|
||||
ChatMessage assistant = new(ChatRole.Assistant, "Regular response");
|
||||
(assistant.AdditionalProperties ??= [])["someOtherKey"] = "value";
|
||||
|
||||
MessageIndex index = MessageIndex.Create([assistant]);
|
||||
|
||||
Assert.Single(index.Groups);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeByteCountHandlesNullAndNonNullText()
|
||||
{
|
||||
// Mix of messages: one with text (non-null), one without (null Text)
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
];
|
||||
|
||||
int byteCount = MessageIndex.ComputeByteCount(messages);
|
||||
|
||||
// Only "Hello" contributes bytes (5 bytes UTF-8)
|
||||
Assert.Equal(5, byteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTokenCountHandlesNullAndNonNullText()
|
||||
{
|
||||
// Mix: one with text, one without
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello world"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
];
|
||||
|
||||
int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
|
||||
|
||||
// Only "Hello world" contributes tokens (2 words)
|
||||
Assert.Equal(2, tokenCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple tokenizer that counts whitespace-separated words as tokens.
|
||||
/// </summary>
|
||||
private sealed class SimpleWordTokenizer : Microsoft.ML.Tokenizers.Tokenizer
|
||||
{
|
||||
public override Microsoft.ML.Tokenizers.PreTokenizer? PreTokenizer => null;
|
||||
public override Microsoft.ML.Tokenizers.Normalizer? Normalizer => null;
|
||||
|
||||
protected override Microsoft.ML.Tokenizers.EncodeResults<Microsoft.ML.Tokenizers.EncodedToken> EncodeToTokens(string? text, System.ReadOnlySpan<char> textSpan, Microsoft.ML.Tokenizers.EncodeSettings settings)
|
||||
{
|
||||
// Simple word-based encoding
|
||||
string input = text ?? textSpan.ToString();
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return new Microsoft.ML.Tokenizers.EncodeResults<Microsoft.ML.Tokenizers.EncodedToken>
|
||||
{
|
||||
Tokens = System.Array.Empty<Microsoft.ML.Tokenizers.EncodedToken>(),
|
||||
CharsConsumed = 0,
|
||||
NormalizedText = null,
|
||||
};
|
||||
}
|
||||
|
||||
string[] words = input.Split(' ');
|
||||
List<Microsoft.ML.Tokenizers.EncodedToken> tokens = [];
|
||||
int offset = 0;
|
||||
for (int i = 0; i < words.Length; i++)
|
||||
{
|
||||
tokens.Add(new Microsoft.ML.Tokenizers.EncodedToken(i, words[i], new System.Range(offset, offset + words[i].Length)));
|
||||
offset += words[i].Length + 1;
|
||||
}
|
||||
|
||||
return new Microsoft.ML.Tokenizers.EncodeResults<Microsoft.ML.Tokenizers.EncodedToken>
|
||||
{
|
||||
Tokens = tokens,
|
||||
CharsConsumed = input.Length,
|
||||
NormalizedText = null,
|
||||
};
|
||||
}
|
||||
|
||||
public override OperationStatus Decode(System.Collections.Generic.IEnumerable<int> ids, System.Span<char> destination, out int idsConsumed, out int charsWritten)
|
||||
{
|
||||
idsConsumed = 0;
|
||||
charsWritten = 0;
|
||||
return OperationStatus.Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class PipelineCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncExecutesAllStrategiesInOrderAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<string> executionOrder = [];
|
||||
TestCompactionStrategy strategy1 = new(
|
||||
_ =>
|
||||
{
|
||||
executionOrder.Add("first");
|
||||
return false;
|
||||
});
|
||||
|
||||
TestCompactionStrategy strategy2 = new(
|
||||
_ =>
|
||||
{
|
||||
executionOrder.Add("second");
|
||||
return false;
|
||||
});
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(["first", "second"], executionOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestCompactionStrategy strategy1 = new(_ => false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestCompactionStrategy strategy1 = new(_ => false);
|
||||
TestCompactionStrategy strategy2 = new(_ => true);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncContinuesAfterFirstCompactionAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestCompactionStrategy strategy1 = new(_ => true);
|
||||
TestCompactionStrategy strategy2 = new(_ => false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert — both strategies were called
|
||||
Assert.Equal(1, strategy1.ApplyCallCount);
|
||||
Assert.Equal(1, strategy2.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncComposesStrategiesEndToEndAsync()
|
||||
{
|
||||
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
|
||||
static void ExcludeOldest2(MessageIndex index)
|
||||
{
|
||||
int excluded = 0;
|
||||
foreach (MessageGroup group in index.Groups)
|
||||
{
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
|
||||
{
|
||||
group.IsExcluded = true;
|
||||
excluded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestCompactionStrategy phase1 = new(
|
||||
index =>
|
||||
{
|
||||
ExcludeOldest2(index);
|
||||
return true;
|
||||
});
|
||||
|
||||
TestCompactionStrategy phase2 = new(
|
||||
index =>
|
||||
{
|
||||
ExcludeOldest2(index);
|
||||
return true;
|
||||
});
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(phase1, phase2);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal(2, included.Count);
|
||||
Assert.Equal("You are helpful.", included[0].Text);
|
||||
Assert.Equal("Q3", included[1].Text);
|
||||
|
||||
Assert.Equal(1, phase1.ApplyCallCount);
|
||||
Assert.Equal(1, phase2.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncEmptyPipelineReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
PipelineCompactionStrategy pipeline = new(new List<CompactionStrategy>());
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple test implementation of <see cref="CompactionStrategy"/> that delegates to a synchronous callback.
|
||||
/// </summary>
|
||||
private sealed class TestCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
private readonly Func<MessageIndex, bool> _applyFunc;
|
||||
|
||||
public TestCompactionStrategy(Func<MessageIndex, bool> applyFunc)
|
||||
: base(CompactionTriggers.Always)
|
||||
{
|
||||
this._applyFunc = applyFunc;
|
||||
}
|
||||
|
||||
public int ApplyCallCount { get; private set; }
|
||||
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
this.ApplyCallCount++;
|
||||
return Task.FromResult(this._applyFunc(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class SlidingWindowCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 3 turns, conversation has 2
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync()
|
||||
{
|
||||
// Arrange — trigger on > 2 turns, conversation has 3
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(2));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// Turn 1 (Q1 + A1) should be excluded
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
// Turn 2 and 3 should remain
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
Assert.False(groups.Groups[4].IsExcluded);
|
||||
Assert.False(groups.Groups[5].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.False(groups.Groups[0].IsExcluded); // System preserved
|
||||
Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded
|
||||
Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded
|
||||
Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
|
||||
new ChatMessage(ChatRole.Tool, "Results"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// Turn 1 excluded
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
// Turn 2 kept (user + tool call group)
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 99 turns
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99));
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal(3, included.Count);
|
||||
Assert.Equal("System", included[0].Text);
|
||||
Assert.Equal("Q2", included[1].Text);
|
||||
Assert.Equal("A2", included[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn, custom target stops after removing 1 turn
|
||||
int removeCount = 0;
|
||||
bool TargetAfterOne(MessageIndex _) => ++removeCount >= 1;
|
||||
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreserved: 0,
|
||||
target: TargetAfterOne);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
new ChatMessage(ChatRole.User, "Q4"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — only turn 1 excluded (target stopped after 1 removal)
|
||||
Assert.True(result);
|
||||
Assert.True(index.Groups[0].IsExcluded); // Q1 (turn 1)
|
||||
Assert.True(index.Groups[1].IsExcluded); // A1 (turn 1)
|
||||
Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept
|
||||
Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncMinimumPreservedStopsCompactionAsync()
|
||||
{
|
||||
// Arrange — always trigger with never-satisfied target, but MinimumPreserved = 2 is hard floor
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreserved: 2,
|
||||
target: _ => false);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — target never says stop, but MinimumPreserved=2 prevents removing the last 2 groups
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, index.IncludedGroupCount);
|
||||
// Last 2 non-system groups must be preserved
|
||||
Assert.False(index.Groups[4].IsExcluded); // Q3
|
||||
Assert.False(index.Groups[5].IsExcluded); // A3
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync()
|
||||
{
|
||||
// Arrange — includes system and pre-excluded groups that must be skipped
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreserved: 0);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
// Pre-exclude one group
|
||||
index.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — system preserved, pre-excluded skipped
|
||||
Assert.True(result);
|
||||
Assert.False(index.Groups[0].IsExcluded); // System preserved
|
||||
}
|
||||
}
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="SummarizationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class SummarizationCompactionStrategyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a mock <see cref="IChatClient"/> that returns the specified summary text.
|
||||
/// </summary>
|
||||
private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.")
|
||||
{
|
||||
Mock<IChatClient> mock = new();
|
||||
mock.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, summaryText)]));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 100000 tokens
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.TokensExceed(100000),
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(2, index.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSummarizesOldGroupsAsync()
|
||||
{
|
||||
// Arrange — always trigger, preserve 1 recent group
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Key facts from earlier."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First question"),
|
||||
new ChatMessage(ChatRole.Assistant, "First answer"),
|
||||
new ChatMessage(ChatRole.User, "Second question"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
|
||||
// Should have: summary + preserved recent group (Second question)
|
||||
Assert.Equal(2, included.Count);
|
||||
Assert.Contains("[Summary]", included[0].Text);
|
||||
Assert.Contains("Key facts from earlier.", included[0].Text);
|
||||
Assert.Equal("Second question", included[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Old question"),
|
||||
new ChatMessage(ChatRole.Assistant, "Old answer"),
|
||||
new ChatMessage(ChatRole.User, "Recent question"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
|
||||
Assert.Equal("You are helpful.", included[0].Text);
|
||||
Assert.Equal(ChatRole.System, included[0].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync()
|
||||
{
|
||||
// Arrange
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Summary text."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — summary should be inserted after system, before preserved group
|
||||
MessageGroup summaryGroup = index.Groups.First(g => g.Kind == MessageGroupKind.Summary);
|
||||
Assert.NotNull(summaryGroup);
|
||||
Assert.Contains("[Summary]", summaryGroup.Messages[0].Text);
|
||||
Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(MessageGroup.SummaryPropertyKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncHandlesEmptyLlmResponseAsync()
|
||||
{
|
||||
// Arrange — LLM returns whitespace
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(" "),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — should use fallback text
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
Assert.Contains("[Summary unavailable]", included[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — preserve 5 but only 2 non-system groups
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 5);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncUsesCustomPromptAsync()
|
||||
{
|
||||
// Arrange — capture the messages sent to the chat client
|
||||
List<ChatMessage>? capturedMessages = null;
|
||||
Mock<IChatClient> mockClient = new();
|
||||
mockClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
capturedMessages = [.. msgs])
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Custom summary.")]));
|
||||
|
||||
const string CustomPrompt = "Summarize in bullet points only.";
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient.Object,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1,
|
||||
summarizationPrompt: CustomPrompt);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — the custom prompt should be the first message sent to the LLM
|
||||
Assert.NotNull(capturedMessages);
|
||||
Assert.Equal(CustomPrompt, capturedMessages![0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSetsExcludeReasonAsync()
|
||||
{
|
||||
// Arrange
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Old"),
|
||||
new ChatMessage(ChatRole.User, "New"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
MessageGroup excluded = index.Groups.First(g => g.IsExcluded);
|
||||
Assert.NotNull(excluded.ExcludeReason);
|
||||
Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTargetStopsMarkingEarlyAsync()
|
||||
{
|
||||
// Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion
|
||||
int exclusionCount = 0;
|
||||
bool TargetAfterOne(MessageIndex _) => ++exclusionCount >= 1;
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Partial summary."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1,
|
||||
target: TargetAfterOne);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — only 1 group should have been summarized (target met after first exclusion)
|
||||
int excludedCount = index.Groups.Count(g => g.IsExcluded);
|
||||
Assert.Equal(1, excludedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesMultipleRecentGroupsAsync()
|
||||
{
|
||||
// Arrange — preserve 2
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Summary."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 2);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — 2 oldest excluded, 2 newest preserved + 1 summary inserted
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
Assert.Equal(3, included.Count); // summary + Q2 + A2
|
||||
Assert.Contains("[Summary]", included[0].Text);
|
||||
Assert.Equal("Q2", included[1].Text);
|
||||
Assert.Equal("A2", included[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync()
|
||||
{
|
||||
// Arrange — system group between user/assistant groups to exercise skip logic in loop
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.System, "System note"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — summary inserted at 0, system group shifted to index 2
|
||||
Assert.True(result);
|
||||
Assert.Equal(MessageGroupKind.Summary, index.Groups[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.System, index.Groups[2].Kind);
|
||||
Assert.False(index.Groups[2].IsExcluded); // System never excluded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync()
|
||||
{
|
||||
// Arrange — large MinimumPreserved so maxSummarizable is small, target never stops
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 3,
|
||||
target: _ => false);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
]);
|
||||
|
||||
// Act — should only summarize 6-3 = 3 groups (not all 6)
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — 3 preserved + 1 summary = 4 included
|
||||
Assert.True(result);
|
||||
Assert.Equal(4, index.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWithPreExcludedGroupAsync()
|
||||
{
|
||||
// Arrange — pre-exclude a group so the count and loop both must skip it
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
index.Groups[0].IsExcluded = true; // Pre-exclude Q1
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(index.Groups[0].IsExcluded); // Still excluded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWithEmptyTextMessageInGroupAsync()
|
||||
{
|
||||
// Arrange — a message with null text (FunctionCallContent) in a summarized group
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
];
|
||||
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
|
||||
// Act — the tool-call group's message has null text
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — compaction succeeded despite null text
|
||||
Assert.True(result);
|
||||
}
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="ToolResultCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class ToolResultCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 1000 tokens
|
||||
ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
|
||||
ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "What's the weather?"),
|
||||
toolCall,
|
||||
toolResult,
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCollapsesOldToolGroupsAsync()
|
||||
{
|
||||
// Arrange — always trigger
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, "Sunny and 72°F"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
// Q1 + collapsed tool summary + Q2
|
||||
Assert.Equal(3, included.Count);
|
||||
Assert.Equal("Q1", included[0].Text);
|
||||
Assert.Contains("[Tool calls: get_weather]", included[1].Text);
|
||||
Assert.Equal("Q2", included[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesRecentToolGroupsAsync()
|
||||
{
|
||||
// Arrange — protect 2 recent non-system groups (the tool group + Q2)
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 3);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
|
||||
new ChatMessage(ChatRole.Tool, "Results"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — all groups are in the protected window, nothing to collapse
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("You are helpful.", included[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncExtractsMultipleToolNamesAsync()
|
||||
{
|
||||
// Arrange — assistant calls two tools
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 1);
|
||||
|
||||
ChatMessage multiToolCall = new(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("c1", "get_weather"),
|
||||
new FunctionCallContent("c2", "search_docs"),
|
||||
]);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
multiToolCall,
|
||||
new ChatMessage(ChatRole.Tool, "Sunny"),
|
||||
new ChatMessage(ChatRole.Tool, "Found 3 docs"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
string collapsed = included[1].Text!;
|
||||
Assert.Contains("get_weather", collapsed);
|
||||
Assert.Contains("search_docs", collapsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncNoToolGroupsReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger fires but no tool groups to collapse
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 0);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync()
|
||||
{
|
||||
// Arrange — compound: tokens > 0 AND has tool calls
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
CompactionTriggers.All(
|
||||
CompactionTriggers.TokensExceed(0),
|
||||
CompactionTriggers.HasToolCalls()),
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTargetStopsCollapsingEarlyAsync()
|
||||
{
|
||||
// Arrange — 2 tool groups, target met after first collapse
|
||||
int collapseCount = 0;
|
||||
CompactionTrigger targetAfterOne = _ => ++collapseCount >= 1;
|
||||
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 1,
|
||||
target: targetAfterOne);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]),
|
||||
new ChatMessage(ChatRole.Tool, "result1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]),
|
||||
new ChatMessage(ChatRole.Tool, "result2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — only first tool group collapsed, second left intact
|
||||
Assert.True(result);
|
||||
|
||||
// Count collapsed tool groups (excluded with ToolCall kind)
|
||||
int collapsedToolGroups = 0;
|
||||
foreach (MessageGroup group in index.Groups)
|
||||
{
|
||||
if (group.IsExcluded && group.Kind == MessageGroupKind.ToolCall)
|
||||
{
|
||||
collapsedToolGroups++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(1, collapsedToolGroups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
|
||||
{
|
||||
// Arrange — pre-excluded and system groups in the enumeration
|
||||
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 0);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "Result 1"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
];
|
||||
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
// Pre-exclude the user group
|
||||
index.Groups[index.Groups.Count - 1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — system never excluded, pre-excluded skipped
|
||||
Assert.True(result);
|
||||
Assert.False(index.Groups[0].IsExcluded); // System stays
|
||||
}
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class TruncationCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync()
|
||||
{
|
||||
// Arrange — always-trigger means always compact
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 1000 tokens, conversation is tiny
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
minimumPreserved: 1,
|
||||
trigger: CompactionTriggers.TokensExceed(1000));
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync()
|
||||
{
|
||||
// Arrange — trigger on groups > 2
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
minimumPreserved: 1,
|
||||
trigger: CompactionTriggers.GroupsExceed(2));
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
// Oldest 2 excluded, newest 2 kept
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// System message should be preserved
|
||||
Assert.False(groups.Groups[0].IsExcluded);
|
||||
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
|
||||
// Oldest non-system groups excluded
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.True(groups.Groups[2].IsExcluded);
|
||||
// Most recent kept
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([assistantToolCall, toolResult, finalResponse]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// Tool call group should be excluded as one atomic unit
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
|
||||
Assert.Equal(2, groups.Groups[0].Messages.Count);
|
||||
Assert.False(groups.Groups[1].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSetsExcludeReasonAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Old"),
|
||||
new ChatMessage(ChatRole.User, "New"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(groups.Groups[0].ExcludeReason);
|
||||
Assert.Contains("TruncationCompactionStrategy", groups.Groups[0].ExcludeReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Already excluded"),
|
||||
new ChatMessage(ChatRole.User, "Included 1"),
|
||||
new ChatMessage(ChatRole.User, "Included 2"),
|
||||
]);
|
||||
groups.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(groups.Groups[0].IsExcluded); // was already excluded
|
||||
Assert.True(groups.Groups[1].IsExcluded); // newly excluded
|
||||
Assert.False(groups.Groups[2].IsExcluded); // kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync()
|
||||
{
|
||||
// Arrange — keep 2 most recent
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncNothingToRemoveReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — preserve 5 but only 2 groups
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 5);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomTargetStopsEarlyAsync()
|
||||
{
|
||||
// Arrange — always trigger, custom target stops after 1 exclusion
|
||||
int targetChecks = 0;
|
||||
bool TargetAfterOne(MessageIndex _) => ++targetChecks >= 1;
|
||||
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1,
|
||||
target: TargetAfterOne);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — only 1 group excluded (target met after first)
|
||||
Assert.True(result);
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.False(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncIncrementalStopsAtTargetAsync()
|
||||
{
|
||||
// Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2)
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
CompactionTriggers.GroupsExceed(2),
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act — 5 groups, trigger fires (5 > 2), compacts until groups <= 2
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — should stop at 2 included groups (not go all the way to 1)
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync()
|
||||
{
|
||||
// Arrange — target never stops (always false), so the loop must exit via removed >= maxRemovable
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2, target: CompactionTriggers.Never);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — only 2 removed (maxRemovable = 4 - 2 = 2), 2 preserved
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
|
||||
{
|
||||
// Arrange — has excluded + system groups that the loop must skip
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
// Pre-exclude one group
|
||||
groups.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — system preserved, pre-excluded skipped, A1 removed, Q2 preserved
|
||||
Assert.True(result);
|
||||
Assert.False(groups.Groups[0].IsExcluded); // System
|
||||
Assert.True(groups.Groups[1].IsExcluded); // Pre-excluded Q1
|
||||
Assert.True(groups.Groups[2].IsExcluded); // Newly excluded A1
|
||||
Assert.False(groups.Groups[3].IsExcluded); // Preserved Q2
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
|
||||
+1
-2
@@ -4,7 +4,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI.Responses;
|
||||
@@ -25,7 +24,7 @@ internal sealed class FunctionToolAgentProvider(IConfiguration configuration) :
|
||||
AIFunctionFactory.Create(menuPlugin.GetItemPrice),
|
||||
];
|
||||
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential());
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
|
||||
yield return
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
|
||||
+1
-2
@@ -4,7 +4,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shared.Foundry;
|
||||
using Shared.IntegrationTests;
|
||||
@@ -15,7 +14,7 @@ internal sealed class MarketingAgentProvider(IConfiguration configuration) : Age
|
||||
{
|
||||
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
|
||||
{
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential());
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential());
|
||||
|
||||
yield return
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user