Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot] d3a7a4da87 Initial plan 2026-03-05 16:33:47 +00:00
Chris Rickman 43d226f8ca Add sample to solution 2026-03-05 08:32:04 -08:00
ChrisandGitHub 1a8a58f753 Merge branch 'main' into crickman/feature-compaction-deux 2026-03-05 08:29:23 -08:00
Chris Rickman c513694b2f Remove working solution 2026-03-05 08:27:00 -08:00
Chris Rickman f42863e354 Test update 2026-03-05 08:25:19 -08:00
Chris Rickman 06f55c0494 Update tests 2026-03-05 08:10:16 -08:00
55ddd841b7 Python: Fix Python pyright package scoping and typing remediation (#4426)
* Fix Python pyright package scoping and typing remediation

Implements issue #4407 by removing the root pyright include, adding package-level pyright includes, and resolving pyright/mypy typing issues across Python packages. Also cleans unnecessary casts and applies line-level, rule-specific ignores where external libraries are too dynamic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reduce pyright cost in handoff cloning

Simplify cloned_options construction in HandoffAgentExecutor to avoid expensive TypedDict narrowing/inference in _handoff.py, which was causing pyright to spend a long time in orchestrations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix types

* Fix lint and type-check regressions

Resolve current Python package check failures across lint, pyright, and mypy after recent code changes, including purview/declarative pyright issues and multiple ruff simplification findings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fixed hooks

* Stabilize package tests and test tasks

Resolve cross-package non-integration test failures, simplify streaming type flow, harden locale/culture handling, and standardize package test poe tasks to exclude integration tests where applicable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* lots of small fixes

* Fix current Python test regressions

Address current failing unit tests in azure-ai, bedrock, and azure-cosmos while keeping Bedrock parsing logic inline (no new static helper methods).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* small fixes

* small fixes

* removed pydantic from json

* final updates

* fix core

* fix tests

* fix obser

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 15:32:24 +00:00
westeyandGitHub 4a043c6c66 .NET: Switch auth sample to use Singletons (#4454)
* Switch auth sample to use Singletons

* Address PR comments

* Add comment to warn users to choose the appropriate lifetime for their service
2026-03-05 14:42:46 +00:00
+6
westeyGitHuballiscodeCopilotcrickmancopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>CopilotRoger BarretoEduard van ValkenburgRishabh ChawlaPeter IbekweDmytro StrukBen ThomasEvan Mattson
3fb90a501a .NET: CI Build time end to end improvement (#4208)
* .NET: Upgrade to XUnit 3 and Microsoft Testing Platform (#4176)

* Fix copilot studio integration tests failure (#4209)

* Fix anthropic integration tests and skip reason (#4211)

* Remove accidental add of code coverage for integration tests (#4219)

* Add solution filtered parallel test run (#4226)

* Fix build paths (#4228)

* Fix coverage settings path and trait filter (#4229)

* Add project name filter to solution (#4231)

* Increase Integration Test Parallelism (#4241)

* Increase integration tests threads to 4x (#4242)

* Separate build and test into parallel jobs (#4243)

* Filter src by framework for tests build (#4244)

* Separate build and test into parallel jobs

* Filter source projects by framework for tests build

* Pre-build samples via tests to avoid timeouts (#4245)

* Separate build from run for console sample validation (#4251)

* Address PR comments (#4255)

* Merge and move scripts (#4308)

* .NET: Add Microsoft Fabric sample #3674 (#4230)

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>

* Python: Phase 2: Embedding clients for Ollama, Bedrock, and Azure AI Inference (#4207)

* Phase 2: Embedding clients for Ollama, Bedrock, and Azure AI Inference

Add embedding client implementations to existing provider packages:

- OllamaEmbeddingClient: Text embeddings via Ollama's embed API
- BedrockEmbeddingClient: Text embeddings via Amazon Titan on Bedrock
- AzureAIInferenceEmbeddingClient: Text and image embeddings via Azure AI
  Inference, supporting Content | str input with separate model IDs for
  text (AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID) and image
  (AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID) endpoints

Additional changes:
- Rename EmbeddingCoT -> EmbeddingT, EmbeddingOptionsCoT -> EmbeddingOptionsT
- Add otel_provider_name passthrough to all embedding clients
- Register integration pytest marker in all packages
- Add lazy-loading namespace exports for Ollama and Bedrock embeddings
- Add image embedding sample using Cohere-embed-v3-english
- Add azure-ai-inference dependency to azure-ai package

Part of #1188

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix mypy duplicate name and ruff lint issues

- Rename second 'vector' variable to 'img_vector' in image embedding loop
- Combine nested with statements in tests
- Remove unused result assignments in tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* updates from feedback

* Fix CI failures in embedding usage handling

- Fix Azure AI embedding mypy issues by normalizing vectors to list[float],
  safely accumulating optional usage token fields, and filtering None entries
  before constructing GeneratedEmbeddings
- Avoid Bandit false positive by initializing usage details as an empty dict
- Update OpenAI embedding tests to assert canonical usage keys
  (input_token_count/total_token_count)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* [Purview] Mark responses as responses and fix epoch bug for python long overflow (#4225)

* .NET: Support InvokeMcpTool for declarative workflows (#4204)

* Initial implementation of InvokeMcpTool in declarative workflow

* Cleaned up sample implementation

* Updated sample comments.

* Added missing executor routing attribute

* Fix PR comments.

* Updated based on PR comments.

* Updated based on PR comments.

* Removed unnecessary using statement.

* Update Python package versions to rc2 (#4258)

- Bump core and azure-ai to 1.0.0rc2
- Bump preview packages to 1.0.0b260225
- Update dependencies to >=1.0.0rc2
- Add CHANGELOG entries for changes since rc1
- Update uv.lock

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Fixing issue where OpenTelemetry span is never exported in .NET in-process workflow execution (#4196)

* 1. Add reproduction test for issue #4155: workflow.run Activity never stopped in streaming OffThread path

The WorkflowRunActivity_IsStopped_Streaming_OffThread test demonstrates that
the workflow.run OpenTelemetry Activity created in StreamingRunEventStream.RunLoopAsync
is started but never stopped when using the OffThread/Default streaming execution.
The background run loop keeps running after event consumption completes, so the
using Activity? declaration never disposes until explicit StopAsync() is called.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

2. Fix workflow.run Activity never stopped in streaming OffThread execution (#4155)

The workflow.run OpenTelemetry Activity in StreamingRunEventStream.RunLoopAsync
was scoped to the method lifetime via 'using'. Since the run loop only exits on
cancellation, the Activity was never stopped/exported until explicit disposal.

Fix: Remove 'using' and explicitly dispose the Activity when the workflow reaches
Idle status (all supersteps complete). A safety-net disposal in the finally block
handles cancellation and error paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add root-level workflow.session activity spanning run loop lifetime\n\nImplements two-level telemetry hierarchy per PR feedback from lokitoth:\n- workflow.session: spans the entire run loop / stream lifetime\n- workflow_invoke: per input-to-halt cycle, nested within the session\n\nThis ensures the session activity stays open across multiple turns,\nwhile individual run activities are created and disposed per cycle.\n\nAlso fixes linkedSource CancellationTokenSource disposal leak in\nStreamingRunEventStream (added using declaration)."

* Address Copilot review: fix Activity/CTS disposal, rename activity, add error tag\n\n1. LockstepRunEventStream: Remove 'using' from Activity in async iterator\n   and manually dispose in finally block (fixes #4155 pattern). Also dispose\n   linkedSource CTS in finally to prevent leak.\n2. Tags.cs: Add ErrorMessage (\"error.message\") tag for runtime errors,\n   distinct from BuildErrorMessage (\"build.error.message\").\n3. ActivityNames: Rename WorkflowRun from \"workflow_invoke\" to \"workflow.run\"\n   for cross-language consistency.\n4. WorkflowTelemetryContext: Fix XML doc to say \"outer/parent span\" instead\n   of \"root-level span\".\n5. ObservabilityTests: Assert WorkflowSession absence when DisableWorkflowRun\n   is true.\n6. WorkflowRunActivityStopTests: Fix streaming test race by disposing\n   StreamingRun before asserting activities are stopped.\n7. StreamingRunEventStream/LockstepRunEventStream: Use Tags.ErrorMessage\n   instead of Tags.BuildErrorMessage for runtime error events."

* Review fixes: revert workflow_invoke rename, use 'using' for linkedSource, move SessionStarted earlier\n\n- Revert ActivityNames.WorkflowRun back to \"workflow_invoke\" (OTEL semantic convention contract)\n- Use 'using' declaration for linkedSource CTS in LockstepRunEventStream (no timing sensitivity)\n- Move SessionStarted event before WaitForInputAsync in StreamingRunEventStream to match Lockstep behavior"

* Improve naming and comments in WorkflowRunActivityStopTests"

* Prevent session Activity.Current leak in lockstep mode, add nesting test

Save and restore Activity.Current in LockstepRunEventStream.Start() so the
session activity doesn't leak into caller code via AsyncLocal. Re-establish
Activity.Current = sessionActivity before creating the run activity in
TakeEventStreamAsync to preserve parent-child nesting.

Add test verifying app activities after RunAsync are not parented under the
session, and that the workflow_invoke activity nests under the session."

* Fix stale XML doc: WorkflowRun -> WorkflowInvoke in ObservabilityTests

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python / .NET Samples - Restructure and Improve Samples (Feature Branc… (#4092)

* Python: .NET Samples - Restructure and Improve Samples (Feature Branch) (#4091)

* Moved by agent (#4094)

* Fix readme links

* .NET Samples - Create `04-hosting` learning path step (#4098)

* Agent move

* Agent reorderd

* Remove A2A section from README 

Removed A2A section from the Getting Started README.

* Agent fixed links

* Fix broken sample links in durable-agents README (#4101)

* Initial plan

* Fix broken internal links in documentation

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Revert template link changes; keep only durable-agents README fix

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* .NET Samples - Create `03-workflows` learning path step (#4102)

* Fix solution project path

* Python: Fix broken markdown links to repo resources (outside /docs) (#4105)

* Initial plan

* Fix broken markdown links to repo resources

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Update README to rename .NET Workflows Samples section

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* .NET Samples - Create `02-agents` learning path step (#4107)

* .NET: Fix broken relative link in GroupChatToolApproval README (#4108)

* Initial plan

* Fix broken link in GroupChatToolApproval README

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Update labeler configuration for workflow samples

* .NET - Reorder Agents samples to start from Step01 instead of Step04 (#4110)

* Fix solution

* Resolve new sample paths

* Move new AgentSkills and AgentWithMemory_Step04 samples

* Fix link

* Fix readme path

* fix: update stale dotnet/samples/Durable path reference in AGENTS.md

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Moved new sample

* Update solution

* Resolve merge (new sample)

* Sync to new sample - FoundryAgents_Step21_BingCustomSearch

* Updated README

* .NET Samples - Configuration Naming Update (#4149)

* .NET: Restore AzureFunctions index parity with ConsoleApps under DurableAgents samples (#4221)

* Clean-up `05_host_your_agent`

* Config setting consistency

* Refine samples

* AGENTS.md

* Move new samples

* Re-order samples

* Move new project and fixup solution

* Fixup model config

* Fix up new UT project

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>

* Python: Fix Bedrock embedding test stub missing meta attribute (#4287)

* Fix Bedrock embedding test stub missing meta attribute

* Increase test coverage so gate passes

* Python: (ag-ui): fix approval payloads being re-processed on subsequent conversation turns (#4232)

* Fix ag-ui tool call issue

* Safe json fix

* Python: Update workflow orchestration samples to use AzureOpenAIResponsesClient (#4285)

* Update workflow orchestration samples to use AzureOpenAIResponsesClient

* Fix broken link

* Move scripts to scripts folder

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Rishabh Chawla <rishabhchawla1995@gmail.com>
Co-authored-by: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>

* Fix encoding (#4309)

* Disable Parallelization for WorkflowRunActivityStopTests (#4313)

* Revert parallel disable (#4324)

* .NET: Disable flakey Workflow Observability tests (#4416)

* Disable flakey OffThread test

* Disable additional OffThread test

* Disable a further test

* Disable all observability tests

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Rishabh Chawla <rishabhchawla1995@gmail.com>
Co-authored-by: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-03-05 14:14:33 +00:00
56bba795cb .NET: Add foundry extension samples for python and dotnet (#4359)
* Add foundry extension samples for python and dotnet

* Align foundry extension samples with existing hosted agent patterns

- Fix Python multiagent indentation bug (from_agent_framework ran in both modes)
- Remove hardcoded personal endpoint from appsettings.Development.json
- Rename .NET folders/projects to PascalCase (FoundryMultiAgent, FoundrySingleAgent)
- Upgrade .NET multiagent from net9.0 to net10.0
- Add ManagePackageVersionsCentrally=false and analyzer blocks to .csproj files
- Replace wildcard package versions with fixed versions
- Use alpine Docker images and standard build pattern
- Align agent.yaml structure (template nesting, displayName, resources, authors)
- Convert .NET multiagent from namespace/class to top-level statements
- Add run-requests.http for multiagent sample
- Fix Python requirements.txt (remove dev deps, add agent-framework)
- Add proper copyright headers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Align foundry samples: fix builds, upgrade AgentServer to beta.8

- Fix TargetFrameworks (plural) to override inherited net472 from Directory.Build.props
- Upgrade Azure.AI.AgentServer.AgentFramework to 1.0.0-beta.8 (latest)
- Bump OpenTelemetry packages to 1.12.0 (required by beta.8)
- Fix Roslynator/format errors (imports ordering, BOM, sealed record, target-typed new)
- Verified with docker dotnet format (matching CI pipeline)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Refactor hosted samples to use AIProjectClient.CreateAIAgentAsync

Replace PersistentAgentsClient and manual AzureOpenAIClient setup with
AIProjectClient.CreateAIAgentAsync() from Microsoft.Agents.AI.AzureAI.

- FoundryMultiAgent: Remove Azure.AI.Agents.Persistent, use CreateAIAgentAsync
  for Writer and Reviewer agents with cleanup in finally block
- FoundrySingleAgent: Remove manual GetConnection/AzureOpenAIClient chain,
  use CreateAIAgentAsync with hotel search tool
- Update csproj: add Microsoft.Agents.AI.AzureAI, remove unused packages

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update READMEs to reflect AIProjectClient.CreateAIAgentAsync usage

- Reference Microsoft.Agents.AI.AzureAI and Microsoft.Agents.AI.Workflows packages
- Add Azure AI Developer role requirement for agents/write data action
- Replace PersistentAgentsClient references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add HostedAgents READMEs and Foundry samples to solution

- Create dotnet/samples/05-end-to-end/HostedAgents/README.md with sample index
- Create python/samples/05-end-to-end/hosted_agents/README.md with sample index
- Add FoundryMultiAgent and FoundrySingleAgent to agent-framework-dotnet.slnx

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Python linting: reorder imports before load_dotenv, remove trailing whitespace

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update uv.lock to match latest package versions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix trailing whitespace in foundry_single_agent agent.yaml

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Exclude dotnet.microsoft.com from link checker

This domain intermittently times out in CI, causing flaky markdown
link check failures unrelated to PR changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Align env vars to AZURE_AI_PROJECT_ENDPOINT and default model to gpt-4o-mini

Addresses PR review feedback:
- Rename PROJECT_ENDPOINT to AZURE_AI_PROJECT_ENDPOINT across all
  Foundry samples (dotnet + python) to match existing samples
- Change default model from gpt-4.1-mini to gpt-4o-mini consistently

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Skip flaky test CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync

Tracked in #4398

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove Python foundry samples from PR scope

Python hosted agent samples need further alignment with the azure-ai
package conventions. Removing from this PR to ship .NET samples first.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Narrow linkspector exclusion to dotnet.microsoft.com/download only

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Leo Yao <leoyao@Leos-MacBook-Pro.local>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 11:43:24 +00:00
Chris Rickman 7e2c5ad4e6 Tuning 2026-03-05 03:10:40 -08:00
Chris Rickman 6ce0447ff6 Tests 2026-03-05 03:00:32 -08:00
Chris Rickman defb9dd51c Formatting 2026-03-05 02:51:05 -08:00
Chris Rickman f70423bd99 Cleanup 2026-03-05 02:45:27 -08:00
Chris Rickman 1428286bd1 Formatting 2026-03-05 02:41:26 -08:00
Chris Rickman 7608005dd7 Encoding 2026-03-05 02:36:22 -08:00
Chris Rickman dda15ea4b4 Updated 2026-03-05 02:35:52 -08:00
westeyandGitHub 6dc65dbaa1 .NET: Increase credential timeout for Integration Tests (#4472)
* Increase credential timeout for Integration Tests

* Fix format error.

* Update further tests

* Fix comment

* Rename credentials file and class.

* Fix broken reference.
2026-03-05 10:32:45 +00:00
ChrisandGitHub b275d3410a Merge branch 'main' into crickman/feature-compaction-deux 2026-03-05 02:25:33 -08:00
Chris Rickman 0e8b9b283f Strategies 2026-03-05 02:25:00 -08:00
Chris Rickman eb8406214e Stable 2026-03-05 01:48:40 -08:00
Chris Rickman fcd60daed5 Checkpoint 2026-03-04 23:31:18 -08:00
Chris Rickman 23cf75be3c Checkpoint 2026-03-04 18:12:23 -08:00
d02051dbb6 Python: Add propagate_session to as_tool() for session sharing in agent-as-tool scenarios (#4439)
* Python: Add propagate_session parameter to as_tool() for session sharing

Add opt-in session propagation in agent-as-tool scenarios. When
propagate_session=True, the parent agent's AgentSession is forwarded
to the sub-agent's run() call, allowing both agents to share session
state (history, metadata, session_id).

- Add propagate_session parameter to BaseAgent.as_tool() (default False)
- Include session in additional_function_arguments so it flows to tools
- Add 3 tests for propagation on/off and shared state verification
- Add sample showing session propagation with observability middleware

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clarify propagate_session docstring per review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 23:14:17 +00:00
23644ac6a7 .NET: bug fix for duplicate output on GitHubCopilotAgent (#3981)
* bug fix for duplicate output on GitHubCopilotAgent

* Add Test code for bug fix of duplicate output on GitHubCopilotAgenttT

* update Test code for bug fix of duplicate output on GitHubCopilotAgenttT

* update Test for duplicate output of GitHubCopilotAgent

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
2026-03-04 21:40:34 +00:00
306 changed files with 9082 additions and 2904 deletions
+1
View File
@@ -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/
+109 -48
View File
@@ -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
View File
@@ -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 -- -?
```
+5 -6
View File
@@ -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 -->
+11 -1
View File
@@ -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" />
+3
View File
@@ -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>
+145
View File
@@ -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
View File
@@ -3,5 +3,8 @@
"version": "10.0.100",
"rollForward": "minor",
"allowPrerelease": false
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
@@ -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
@@ -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"]
@@ -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,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
</PropertyGroup>
@@ -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));
@@ -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));
@@ -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;
}
}
@@ -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));
@@ -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));
@@ -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();
}
}
@@ -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);
}
}
@@ -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>
@@ -1,7 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
<ItemGroup>
@@ -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")]
@@ -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();
}
}
@@ -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();
}
}
+8 -5
View File
@@ -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>
@@ -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()
{
@@ -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();
@@ -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;
@@ -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;
@@ -2,7 +2,6 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
@@ -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;
@@ -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";
}
@@ -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);
}
}
@@ -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;
@@ -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;
}
}
}
@@ -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));
}
}
}
@@ -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
}
}
@@ -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);
}
}
@@ -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
}
}
@@ -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" />
@@ -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(
@@ -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