Checkpoint

This commit is contained in:
Chris Rickman
2026-03-04 18:12:23 -08:00
Unverified
parent 869e51fdce
commit 23cf75be3c
20 changed files with 2706 additions and 1 deletions
+1
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 -->
+447
View File
@@ -0,0 +1,447 @@
<Solution>
<Configurations>
<BuildType Name="Debug" />
<BuildType Name="Publish" />
<BuildType Name="Release" />
</Configurations>
<Folder Name="/Samples/">
<File Path="samples/AGENTS.md" />
<File Path="samples/README.md" />
</Folder>
<Folder Name="/Samples/01-get-started/">
<Project Path="samples/01-get-started/01_hello_agent/01_hello_agent.csproj" />
<Project Path="samples/01-get-started/02_add_tools/02_add_tools.csproj" />
<Project Path="samples/01-get-started/03_multi_turn/03_multi_turn.csproj" />
<Project Path="samples/01-get-started/04_memory/04_memory.csproj" />
<Project Path="samples/01-get-started/05_first_workflow/05_first_workflow.csproj" />
<Project Path="samples/01-get-started/06_host_your_agent/06_host_your_agent.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/">
<File Path="samples/02-agents/README.md" />
</Folder>
<Folder Name="/Samples/02-agents/AgentProviders/">
<File Path="samples/02-agents/AgentProviders/README.md" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_A2A/Agent_With_A2A.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_Anthropic/Agent_With_Anthropic.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Agent_With_AzureFoundryModel.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Agent_With_AzureOpenAIChatCompletion.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/Agent_With_AzureOpenAIResponses.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_GitHubCopilot/Agent_With_GitHubCopilot.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_Ollama/Agent_With_Ollama.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_ONNX/Agent_With_ONNX.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants/Agent_With_OpenAIAssistants.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/Agent_With_OpenAIChatCompletion.csproj" />
<Project Path="samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Agent_With_OpenAIResponses.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Agents/">
<File Path="samples/02-agents/Agents/README.md" />
<Project Path="samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Agent_Step01_UsingFunctionToolsWithApprovals.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step02_StructuredOutput/Agent_Step02_StructuredOutput.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step03_PersistedConversations/Agent_Step03_PersistedConversations.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage/Agent_Step04_3rdPartyChatHistoryStorage.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step05_Observability/Agent_Step05_Observability.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step06_DependencyInjection/Agent_Step06_DependencyInjection.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step09_AsFunctionTool/Agent_Step09_AsFunctionTool.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/Agent_Step10_BackgroundResponsesWithToolsAndPersistence.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step11_Middleware/Agent_Step11_Middleware.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step12_Plugins/Agent_Step12_Plugins.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step13_ChatReduction/Agent_Step13_ChatReduction.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step14_BackgroundResponses/Agent_Step14_BackgroundResponses.csproj" />
<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" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/">
<File Path="samples/02-agents/AGUI/README.md" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step01_GettingStarted/">
<Project Path="samples/02-agents/AGUI/Step01_GettingStarted/Client/Client.csproj" />
<Project Path="samples/02-agents/AGUI/Step01_GettingStarted/Server/Server.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step02_BackendTools/">
<Project Path="samples/02-agents/AGUI/Step02_BackendTools/Client/Client.csproj" />
<Project Path="samples/02-agents/AGUI/Step02_BackendTools/Server/Server.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step03_FrontendTools/">
<Project Path="samples/02-agents/AGUI/Step03_FrontendTools/Client/Client.csproj" />
<Project Path="samples/02-agents/AGUI/Step03_FrontendTools/Server/Server.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step04_HumanInLoop/">
<Project Path="samples/02-agents/AGUI/Step04_HumanInLoop/Client/Client.csproj" />
<Project Path="samples/02-agents/AGUI/Step04_HumanInLoop/Server/Server.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentSkills/">
<File Path="samples/02-agents/AgentSkills/README.md" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Server/Server.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DevUI/">
<File Path="samples/02-agents/DevUI/README.md" />
<Project Path="samples/02-agents/DevUI/DevUI_Step01_BasicUsage/DevUI_Step01_BasicUsage.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithAnthropic/">
<File Path="samples/02-agents/AgentWithAnthropic/README.md" />
<Project Path="samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Agent_Anthropic_Step01_Running.csproj" />
<Project Path="samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/Agent_Anthropic_Step02_Reasoning.csproj" />
<Project Path="samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/Agent_Anthropic_Step03_UsingFunctionTools.csproj" />
<Project Path="samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step04_UsingSkills/Agent_Anthropic_Step04_UsingSkills.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithMemory/">
<File Path="samples/02-agents/AgentWithMemory/README.md" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithOpenAI/">
<File Path="samples/02-agents/AgentWithOpenAI/README.md" />
<Project Path="samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
<Project Path="samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
<Project Path="samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Agent_OpenAI_Step03_CreateFromChatClient.csproj" />
<Project Path="samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient.csproj" />
<Project Path="samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Agent_OpenAI_Step05_Conversation.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithRAG/">
<File Path="samples/02-agents/AgentWithRAG/README.md" />
<Project Path="samples/02-agents/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
<Project Path="samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj" />
<Project Path="samples/02-agents/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj" />
<Project Path="samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/FoundryAgents/">
<File Path="samples/02-agents/FoundryAgents/README.md" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/FoundryAgents_Step16_FileSearch.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/FoundryAgents_Step17_OpenAPITools.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/FoundryAgents_Step18_BingCustomSearch.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/FoundryAgents_Step19_SharePoint.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/FoundryAgents_Step20_MicrosoftFabric.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/FoundryAgents_Step21_WebSearch.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj" />
<Project Path="samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/FoundryAgents_Step23_LocalMCP.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Observability/">
<Project Path="samples/02-agents/AgentOpenTelemetry/AgentOpenTelemetry.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/">
<File Path="samples/03-workflows/README.md" />
</Folder>
<Folder Name="/Samples/03-workflows/Concurrent/">
<Project Path="samples/03-workflows/Concurrent/Concurrent/Concurrent.csproj" />
<Project Path="samples/03-workflows/Concurrent/MapReduce/MapReduce.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/ConditionalEdges/">
<Project Path="samples/03-workflows/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj" />
<Project Path="samples/03-workflows/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj" />
<Project Path="samples/03-workflows/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Declarative/">
<File Path="samples/03-workflows/Declarative/README.md" />
<Project Path="samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj" />
<Project Path="samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj" />
<Project Path="samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj" />
<Project Path="samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj" />
<Project Path="samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj" />
<Project Path="samples/03-workflows/Declarative/GenerateCode/GenerateCode.csproj" />
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
<Project Path="samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
<Project Path="samples/03-workflows/Declarative/ToolApproval/ToolApproval.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Declarative/Examples/">
<File Path="../workflow-samples/CustomerSupport.yaml" />
<File Path="../workflow-samples/DeepResearch.yaml" />
<File Path="../workflow-samples/Marketing.yaml" />
<File Path="../workflow-samples/MathChat.yaml" />
<File Path="../workflow-samples/README.md" />
<File Path="../workflow-samples/wttr.json" />
</Folder>
<Folder Name="/Samples/03-workflows/SharedStates/">
<Project Path="samples/03-workflows/SharedStates/SharedStates.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Loop/">
<Project Path="samples/03-workflows/Loop/Loop.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Agents/">
<Project Path="samples/03-workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj" />
<Project Path="samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj" />
<Project Path="samples/03-workflows/Agents/GroupChatToolApproval/GroupChatToolApproval.csproj" />
<Project Path="samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Checkpoint/">
<Project Path="samples/03-workflows/Checkpoint/CheckpointAndRehydrate/CheckpointAndRehydrate.csproj" />
<Project Path="samples/03-workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj" />
<Project Path="samples/03-workflows/Checkpoint/CheckpointWithHumanInTheLoop/CheckpointWithHumanInTheLoop.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/HumanInTheLoop/">
<Project Path="samples/03-workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Observability/">
<Project Path="samples/03-workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
<Project Path="samples/03-workflows/Observability/AspireDashboard/AspireDashboard.csproj" />
<Project Path="samples/03-workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Visualization/">
<Project Path="samples/03-workflows/Visualization/Visualization.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/_StartHere/">
<Project Path="samples/03-workflows/_StartHere/01_Streaming/01_Streaming.csproj" />
<Project Path="samples/03-workflows/_StartHere/02_AgentsInWorkflows/02_AgentsInWorkflows.csproj" />
<Project Path="samples/03-workflows/_StartHere/03_AgentWorkflowPatterns/03_AgentWorkflowPatterns.csproj" />
<Project Path="samples/03-workflows/_StartHere/04_MultiModelService/04_MultiModelService.csproj" />
<Project Path="samples/03-workflows/_StartHere/05_SubWorkflows/05_SubWorkflows.csproj" />
<Project Path="samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/06_MixedWorkflowAgentsAndExecutors.csproj" />
<Project Path="samples/03-workflows/_StartHere/07_WriterCriticWorkflow/07_WriterCriticWorkflow.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/" />
<Folder Name="/Samples/04-hosting/DurableAgents/" />
<Folder Name="/Samples/04-hosting/DurableAgents/AzureFunctions/">
<File Path="samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig" />
<File Path="samples/04-hosting/DurableAgents/AzureFunctions/README.md" />
<Project Path="samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj" />
<Project Path="samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj" />
<Project Path="samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj" />
<Project Path="samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj" />
<Project Path="samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
<Project Path="samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
<Project Path="samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/DurableAgents/ConsoleApps/">
<File Path="samples/04-hosting/DurableAgents/ConsoleApps/README.md" />
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/01_SingleAgent/01_SingleAgent.csproj" />
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj" />
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj" />
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj" />
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/A2A/">
<File Path="samples/04-hosting/A2A/README.md" />
<Project Path="samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
<Project Path="samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/">
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/A2AClientServer/">
<File Path="samples/05-end-to-end/A2AClientServer/README.md" />
<Project Path="samples/05-end-to-end/A2AClientServer/A2AClient/A2AClient.csproj" />
<Project Path="samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/AgentWebChat/">
<Project Path="samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj" />
<Project Path="samples/05-end-to-end/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj" />
<Project Path="samples/05-end-to-end/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj" />
<Project Path="samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/AGUIClientServer/">
<File Path="samples/05-end-to-end/AGUIClientServer/README.md" />
<Project Path="samples/05-end-to-end/AGUIClientServer/AGUIClient/AGUIClient.csproj" />
<Project Path="samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
<Project Path="samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/HostedAgents/">
<Project Path="samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/AspNetAgentAuthorization/">
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml" />
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/README.md" />
<Project Path="samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj" />
<Project Path="samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj" />
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".gitignore" />
<File Path="AGENTS.md" />
<File Path="Directory.Build.props" />
<File Path="Directory.Build.targets" />
<File Path="Directory.Packages.props" />
<File Path="global.json" />
<File Path="nuget.config" />
<File Path="README.md" />
</Folder>
<Folder Name="/Solution Items/.github/" />
<Folder Name="/Solution Items/.github/upgrades/" />
<Folder Name="/Solution Items/.github/upgrades/prompts/">
<File Path="../.github/upgrades/prompts/SemanticKernelToAgentFramework.md" />
</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/">
<File Path="demos/.editorconfig" />
<File Path="demos/Directory.Build.props" />
</Folder>
<Folder Name="/Solution Items/docs/" />
<Folder Name="/Solution Items/docs/decisions/">
<File Path="../docs/decisions/0001-agent-run-response.md" />
<File Path="../docs/decisions/0002-agent-tools.md" />
<File Path="../docs/decisions/0003-agent-opentelemetry-instrumentation.md" />
<File Path="../docs/decisions/0004-foundry-sdk-extensions.md" />
<File Path="../docs/decisions/0005-python-naming-conventions.md" />
<File Path="../docs/decisions/0006-userapproval.md" />
<File Path="../docs/decisions/0007-agent-filtering-middleware.md" />
<File Path="../docs/decisions/0008-python-subpackages.md" />
<File Path="../docs/decisions/0009-support-long-running-operations.md" />
<File Path="../docs/decisions/0010-ag-ui-support.md" />
<File Path="../docs/decisions/0011-create-get-agent-api.md" />
<File Path="../docs/decisions/0012-python-typeddict-options.md" />
<File Path="../docs/decisions/0013-python-get-response-simplification.md" />
<File Path="../docs/decisions/0014-feature-collections.md" />
<File Path="../docs/decisions/0015-agent-run-context.md" />
<File Path="../docs/decisions/0016-python-context-middleware.md" />
<File Path="../docs/decisions/0017-agent-additional-properties.md" />
<File Path="../docs/decisions/0018-agentthread-serialization.md" />
<File Path="../docs/decisions/adr-short-template.md" />
<File Path="../docs/decisions/adr-template.md" />
<File Path="../docs/decisions/README.md" />
</Folder>
<Folder Name="/Solution Items/eng/" />
<Folder Name="/Solution Items/eng/MSBuild/">
<File Path="eng/MSBuild/LegacySupport.props" />
<File Path="eng/MSBuild/Shared.props" />
<File Path="eng/MSBuild/Shared.targets" />
</Folder>
<Folder Name="/Solution Items/nuget/">
<File Path="nuget/icon.png" />
<File Path="nuget/nuget-package.props" />
<File Path="nuget/NUGET.md" />
</Folder>
<Folder Name="/Solution Items/samples/">
<File Path="samples/.editorconfig" />
<File Path="samples/Directory.Build.props" />
</Folder>
<Folder Name="/Solution Items/src/" />
<Folder Name="/Solution Items/src/LegacySupport/">
<File Path="src/LegacySupport/README.md" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/CallerAttributes/">
<File Path="src/LegacySupport/CallerAttributes/CallerArgumentExpressionAttribute.cs" />
<File Path="src/LegacySupport/CallerAttributes/README.md" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/CompilerFeatureRequiredAttribute/">
<File Path="src/LegacySupport/CompilerFeatureRequiredAttribute/CompilerFeatureRequiredAttribute.cs" />
<File Path="src/LegacySupport/CompilerFeatureRequiredAttribute/README.md" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/DiagnosticAttributes/">
<File Path="src/LegacySupport/DiagnosticAttributes/NullableAttributes.cs" />
<File Path="src/LegacySupport/DiagnosticAttributes/README.md" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/DiagnosticClasses/">
<File Path="src/LegacySupport/DiagnosticClasses/README.md" />
<File Path="src/LegacySupport/DiagnosticClasses/UnreachableException.cs" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/ExperimentalAttribute/">
<File Path="src/LegacySupport/ExperimentalAttribute/ExperimentalAttribute.cs" />
<File Path="src/LegacySupport/ExperimentalAttribute/README.md" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/IsExternalInit/">
<File Path="src/LegacySupport/IsExternalInit/IsExternalInit.cs" />
<File Path="src/LegacySupport/IsExternalInit/README.md" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/RequiredMemberAttribute/">
<File Path="src/LegacySupport/RequiredMemberAttribute/README.md" />
<File Path="src/LegacySupport/RequiredMemberAttribute/RequiredMemberAttribute.cs" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/TrimAttributes/">
<File Path="src/LegacySupport/TrimAttributes/DynamicallyAccessedMembersAttribute.cs" />
<File Path="src/LegacySupport/TrimAttributes/DynamicallyAccessedMemberTypes.cs" />
<File Path="src/LegacySupport/TrimAttributes/README.md" />
<File Path="src/LegacySupport/TrimAttributes/RequiresDynamicCodeAttribute.cs" />
<File Path="src/LegacySupport/TrimAttributes/RequiresUnreferencedCodeAttribute.cs" />
<File Path="src/LegacySupport/TrimAttributes/UnconditionalSuppressMessageAttribute.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/" />
<Folder Name="/Solution Items/src/Shared/Demos/">
<File Path="src/Shared/Demos/README.md" />
<File Path="src/Shared/Demos/SampleEnvironment.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/DiagnosticIds/">
<File Path="src/Shared/DiagnosticIds/DiagnosticsIds.cs" />
<File Path="src/Shared/DiagnosticIds/README.md" />
</Folder>
<Folder Name="/Solution Items/src/Shared/IntegrationTests/">
<File Path="src/Shared/IntegrationTests/AnthropicConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/AzureAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/Mem0Configuration.cs" />
<File Path="src/Shared/IntegrationTests/OpenAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/README.md" />
</Folder>
<Folder Name="/Solution Items/src/Shared/Samples/">
<File Path="src/Shared/Samples/BaseSample.cs" />
<File Path="src/Shared/Samples/README.md" />
<File Path="src/Shared/Samples/TestConfiguration.cs" />
<File Path="src/Shared/Samples/TextOutputHelperExtensions.cs" />
<File Path="src/Shared/Samples/XunitLogger.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/Throw/">
<File Path="src/Shared/Throw/README.md" />
<File Path="src/Shared/Throw/Throw.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/StructuredOutput/">
<File Path="src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs" />
</Folder>
<Folder Name="/Solution Items/tests/">
<File Path="tests/.editorconfig" />
<File Path="tests/Directory.Build.props" />
</Folder>
<Folder Name="/src/">
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
</Folder>
<Folder Name="/Tests/" />
<Folder Name="/Tests/IntegrationTests/" />
<Folder Name="/Tests/UnitTests/">
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
</Folder>
</Solution>
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -269,6 +270,35 @@ public abstract class ChatHistoryProvider
protected virtual ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
default;
/// <summary>
/// Compacts the messages in place using the specified compaction strategy before they are stored.
/// </summary>
/// <param name="messages">The messages to compact. This list is mutated in place.</param>
/// <param name="compactionStrategy">The compaction strategy to apply.</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.</returns>
/// <remarks>
/// <para>
/// This method organizes the messages into atomic <see cref="MessageGroup"/> units,
/// applies the compaction strategy, and replaces the contents of the list with the compacted result.
/// Tool call groups (assistant message + tool results) are treated as atomic units.
/// </para>
/// </remarks>
protected static async Task<bool> CompactMessagesAsync(List<ChatMessage> messages, ICompactionStrategy compactionStrategy, CancellationToken cancellationToken = default)
{
MessageGroups groups = MessageGroups.Create(messages);
bool compacted = await compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
if (compacted)
{
messages.Clear();
messages.AddRange(groups.GetIncludedMessages());
}
return compacted;
}
/// <summary>Asks the <see cref="ChatHistoryProvider"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Defines a strategy for compacting a <see cref="MessageGroups"/> to reduce context size.
/// </summary>
/// <remarks>
/// <para>
/// Compaction strategies operate on <see cref="MessageGroups"/> 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>
/// 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="MessageGroups"/>.
/// </para>
/// </remarks>
public interface ICompactionStrategy
{
/// <summary>
/// Compacts the specified message groups in place.
/// </summary>
/// <param name="groups">The message group collection 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>
Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,108 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
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="MessageGroups"/> can efficiently aggregate totals across all or only included groups.
/// These values are computed by <see cref="MessageGroups.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="MessageGroups.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>
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="MessageGroups.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,49 @@
// 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,
/// <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>
Summary,
}
@@ -0,0 +1,312 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
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="MessageGroups"/> 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>
/// </remarks>
public sealed class MessageGroups
{
/// <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>
/// Initializes a new instance of the <see cref="MessageGroups"/> 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 MessageGroups(IList<MessageGroup> groups, Tokenizer? tokenizer = null)
{
this.Groups = groups;
this.Tokenizer = tokenizer;
}
/// <summary>
/// Creates a <see cref="MessageGroups"/> 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="MessageGroups"/> 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 MessageGroups Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
{
List<MessageGroup> groups = [];
int index = 0;
int currentTurn = 0;
while (index < messages.Count)
{
ChatMessage message = messages[index];
if (message.Role == ChatRole.System)
{
// System messages are not part of any turn
groups.Add(CreateGroup(MessageGroupKind.System, [message], tokenizer, turnIndex: null));
index++;
}
else if (message.Role == ChatRole.User)
{
currentTurn++;
groups.Add(CreateGroup(MessageGroupKind.User, [message], tokenizer, 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++;
}
groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, tokenizer, currentTurn));
}
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
{
groups.Add(CreateGroup(MessageGroupKind.Summary, [message], tokenizer, currentTurn));
index++;
}
else
{
groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], tokenizer, currentTurn));
index++;
}
}
return new MessageGroups(groups, tokenizer);
}
/// <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);
#region Total aggregates (all groups, including excluded)
/// <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);
#endregion
#region Included aggregates (non-excluded groups only)
/// <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);
#endregion
#region Turn aggregates
/// <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);
#endregion
/// <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)
{
if (message.Contents is null)
{
return false;
}
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,82 @@
// 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="ICompactionStrategy"/> instances
/// against the same <see cref="MessageGroups"/>.
/// </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>
/// When <see cref="EarlyStop"/> is <see langword="true"/> and a <see cref="TargetIncludedGroupCount"/> is configured,
/// the pipeline stops executing after a strategy reduces the included group count to or below the target.
/// This avoids unnecessary work when an earlier strategy is sufficient.
/// </para>
/// </remarks>
public sealed class PipelineCompactionStrategy : ICompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
/// <param name="strategies">The ordered sequence of strategies to execute. Must not be empty.</param>
public PipelineCompactionStrategy(params IEnumerable<ICompactionStrategy> strategies)
{
this.Strategies = [.. Throw.IfNull(strategies)];
}
/// <summary>
/// Gets the ordered list of strategies in this pipeline.
/// </summary>
public IReadOnlyList<ICompactionStrategy> Strategies { get; }
/// <summary>
/// Gets or sets a value indicating whether the pipeline should stop executing after a strategy
/// brings the included group count to or below <see cref="TargetIncludedGroupCount"/>.
/// </summary>
/// <value>
/// Defaults to <see langword="false"/>, meaning all strategies are always executed.
/// </value>
public bool EarlyStop { get; set; }
/// <summary>
/// Gets or sets the target number of included groups at which the pipeline stops
/// when <see cref="EarlyStop"/> is <see langword="true"/>.
/// </summary>
/// <value>
/// Defaults to <see langword="null"/>, meaning early stop checks are not performed
/// even when <see cref="EarlyStop"/> is <see langword="true"/>.
/// </value>
public int? TargetIncludedGroupCount { get; set; }
/// <inheritdoc/>
public async Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
{
bool anyCompacted = false;
foreach (ICompactionStrategy strategy in this.Strategies)
{
bool compacted = await strategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
if (compacted)
{
anyCompacted = true;
}
if (this.EarlyStop && this.TargetIncludedGroupCount is int targetIncludedGroupCount && groups.IncludedGroupCount <= targetIncludedGroupCount)
{
break;
}
}
return anyCompacted;
}
}
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -46,6 +47,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
options?.JsonSerializerOptions);
this.ChatReducer = options?.ChatReducer;
this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval;
this.CompactionStrategy = options?.CompactionStrategy;
}
/// <inheritdoc />
@@ -61,6 +63,11 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// </summary>
public InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent ReducerTriggerEvent { get; }
/// <summary>
/// Gets the compaction strategy used to compact stored messages. If <see langword="null"/>, no compaction is applied.
/// </summary>
public ICompactionStrategy? CompactionStrategy { get; }
/// <summary>
/// Gets the chat messages stored for the specified session.
/// </summary>
@@ -109,6 +116,36 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
}
// Apply compaction strategy if configured (pre-write compaction)
if (this.CompactionStrategy is not null)
{
await CompactMessagesAsync(state.Messages, this.CompactionStrategy, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Compacts the stored messages for the specified session using the given or configured compaction strategy.
/// </summary>
/// <param name="session">The agent session whose stored messages should be compacted.</param>
/// <param name="compactionStrategy">
/// An optional compaction strategy to use. If <see langword="null"/>, the provider's configured
/// <see cref="CompactionStrategy"/> is used. If neither is available, an <see cref="InvalidOperationException"/> is thrown.
/// </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.</returns>
/// <exception cref="InvalidOperationException">No compaction strategy is configured or provided.</exception>
/// <remarks>
/// This method enables on-demand compaction of stored history, for example as a maintenance operation.
/// It reads the full stored history, applies the compaction strategy, and writes the compacted result back.
/// </remarks>
public async Task<bool> CompactStorageAsync(AgentSession? session, ICompactionStrategy? compactionStrategy = null, CancellationToken cancellationToken = default)
{
ICompactionStrategy strategy = compactionStrategy ?? this.CompactionStrategy
?? throw new InvalidOperationException("No compaction strategy is configured or provided.");
var state = this._sessionState.GetOrInitializeState(session);
return await CompactMessagesAsync(state.Messages, strategy, cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
@@ -73,6 +74,21 @@ public sealed class InMemoryChatHistoryProviderOptions
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? ProvideOutputMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional <see cref="ICompactionStrategy"/> to apply to stored messages after new messages are added.
/// </summary>
/// <remarks>
/// <para>
/// When set, this strategy is applied to the full stored message list after new messages have been appended.
/// This enables pre-write compaction to limit storage size.
/// </para>
/// <para>
/// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings)
/// before applying the strategy logic. See <see cref="ICompactionStrategy"/> for details.
/// </para>
/// </remarks>
public ICompactionStrategy? CompactionStrategy { get; set; }
/// <summary>
/// Defines the events that can trigger a reducer in the <see cref="InMemoryChatHistoryProvider"/>.
/// </summary>
@@ -29,6 +29,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
</ItemGroup>
<ItemGroup>
@@ -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,26 @@ public sealed class ChatClientAgentOptions
/// </summary>
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
/// <summary>
/// Gets or sets the <see cref="ICompactionStrategy"/> 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="ICompactionStrategy"/> for details.
/// </para>
/// <para>
/// This is separate from the compaction strategy on <see cref="InMemoryChatHistoryProviderOptions.CompactionStrategy"/>,
/// which applies pre-write compaction before storing messages. Both can be used together.
/// </para>
/// </remarks>
public ICompactionStrategy? 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 +122,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,
@@ -53,9 +53,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,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating <see cref="IChatClient"/> that applies an <see cref="ICompactionStrategy"/> 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 <see cref="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 ICompactionStrategy _compactionStrategy;
/// <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, ICompactionStrategy compactionStrategy)
: base(innerClient)
{
this._compactionStrategy = Throw.IfNull(compactionStrategy);
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
List<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)
{
List<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
await foreach (var update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
private async Task<List<ChatMessage>> ApplyCompactionAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
{
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages];
MessageGroups groups = MessageGroups.Create(messageList);
bool compacted = await this._compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
return compacted ? [.. groups.GetIncludedMessages()] : messageList;
}
}
@@ -0,0 +1,135 @@
// Copyright (c) Microsoft. All rights reserved.
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 summarizes older message groups using an <see cref="IChatClient"/>,
/// replacing them with a single summary message.
/// </summary>
/// <remarks>
/// <para>
/// When the number of included message groups exceeds <see cref="MaxGroupsBeforeSummary"/>,
/// this strategy extracts the oldest non-system groups (up to the threshold), sends them
/// to an <see cref="IChatClient"/> for summarization, and replaces those groups with a single
/// assistant message containing the summary.
/// </para>
/// <para>
/// System message groups are always preserved and never included in summarization.
/// </para>
/// </remarks>
public sealed class SummarizationCompactionStrategy : ICompactionStrategy
{
private const string DefaultSummarizationPrompt =
"Summarize the following conversation concisely, preserving key facts, decisions, and context. " +
"Focus on information that would be needed to continue the conversation effectively.";
/// <summary>
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
/// <param name="chatClient">The chat client to use for generating summaries.</param>
/// <param name="maxGroupsBeforeSummary">The maximum number of included groups allowed before summarization is triggered.</param>
/// <param name="summarizationPrompt">Optional custom prompt for the summarization request. If <see langword="null"/>, a default prompt is used.</param>
public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBeforeSummary, string? summarizationPrompt = null)
{
this.ChatClient = Throw.IfNull(chatClient);
this.MaxGroupsBeforeSummary = maxGroupsBeforeSummary;
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
}
/// <summary>
/// Gets the chat client used for generating summaries.
/// </summary>
public IChatClient ChatClient { get; }
/// <summary>
/// Gets the maximum number of included groups allowed before summarization is triggered.
/// </summary>
public int MaxGroupsBeforeSummary { get; }
/// <summary>
/// Gets the prompt used when requesting summaries from the chat client.
/// </summary>
public string SummarizationPrompt { get; }
/// <inheritdoc/>
public async Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
{
int includedCount = groups.IncludedGroupCount;
if (includedCount <= this.MaxGroupsBeforeSummary)
{
return false;
}
// Determine how many groups to summarize (keep the most recent MaxGroupsBeforeSummary groups)
int groupsToSummarize = includedCount - this.MaxGroupsBeforeSummary;
// Collect the oldest non-system included groups for summarization
StringBuilder conversationText = new();
int summarized = 0;
int insertIndex = -1;
for (int i = 0; i < groups.Groups.Count && summarized < groupsToSummarize; i++)
{
MessageGroup group = groups.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 SummarizationCompactionStrategy";
summarized++;
}
if (summarized == 0)
{
return false;
}
// Generate summary using the chat client
ChatResponse response = await this.ChatClient.GetResponseAsync(
[
new ChatMessage(ChatRole.System, this.SummarizationPrompt),
new ChatMessage(ChatRole.User, conversationText.ToString()),
],
cancellationToken: cancellationToken).ConfigureAwait(false);
string summaryText = response.Text ?? string.Empty;
// Insert a summary group at the position of the first summarized group
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary of earlier conversation]: {summaryText}");
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
if (insertIndex >= 0)
{
groups.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
}
else
{
groups.AddGroup(MessageGroupKind.Summary, [summaryMessage]);
}
return true;
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that keeps the most recent message groups up to a specified limit,
/// optionally preserving system message groups.
/// </summary>
/// <remarks>
/// <para>
/// This strategy implements a sliding window approach: it marks older groups as excluded
/// while keeping the most recent groups within the configured <see cref="MaxGroups"/> limit.
/// System message groups can optionally be preserved regardless of their position.
/// </para>
/// <para>
/// This strategy respects atomic group preservation — tool call groups (assistant message + tool results)
/// are always kept or excluded together.
/// </para>
/// </remarks>
public sealed class TruncationCompactionStrategy : ICompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
/// <param name="maxGroups">The maximum number of message groups to keep. Must be greater than zero.</param>
/// <param name="preserveSystemMessages">Whether to preserve system message groups regardless of position. Defaults to <see langword="true"/>.</param>
public TruncationCompactionStrategy(int maxGroups, bool preserveSystemMessages = true)
{
this.MaxGroups = maxGroups;
this.PreserveSystemMessages = preserveSystemMessages;
}
/// <summary>
/// Gets the maximum number of message groups to retain after compaction.
/// </summary>
public int MaxGroups { get; }
/// <summary>
/// Gets a value indicating whether system message groups are preserved regardless of their position in the conversation.
/// </summary>
public bool PreserveSystemMessages { get; }
/// <inheritdoc/>
public Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
{
int includedCount = groups.IncludedGroupCount;
if (includedCount <= this.MaxGroups)
{
return Task.FromResult(false);
}
int excessCount = includedCount - this.MaxGroups;
bool compacted = false;
// Exclude oldest non-system groups first (iterate from the beginning)
for (int i = 0; i < groups.Groups.Count && excessCount > 0; i++)
{
MessageGroup group = groups.Groups[i];
if (group.IsExcluded)
{
continue;
}
if (this.PreserveSystemMessages && group.Kind == MessageGroupKind.System)
{
continue;
}
group.IsExcluded = true;
group.ExcludeReason = "Truncated by TruncationCompactionStrategy";
excessCount--;
compacted = true;
}
return Task.FromResult(compacted);
}
}
@@ -0,0 +1,268 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
/// <summary>
/// Contains tests for the compaction integration with <see cref="InMemoryChatHistoryProvider"/>.
/// </summary>
public class InMemoryChatHistoryProviderCompactionTests
{
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
private static AgentSession CreateMockSession() => new Mock<AgentSession>().Object;
[Fact]
public void Constructor_SetsCompactionStrategy_FromOptions()
{
// Arrange
Mock<ICompactionStrategy> strategy = new();
// Act
InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
{
CompactionStrategy = strategy.Object,
});
// Assert
Assert.Same(strategy.Object, provider.CompactionStrategy);
}
[Fact]
public void Constructor_CompactionStrategyIsNull_ByDefault()
{
// Arrange & Act
InMemoryChatHistoryProvider provider = new();
// Assert
Assert.Null(provider.CompactionStrategy);
}
[Fact]
public async Task StoreChatHistoryAsync_AppliesCompaction_WhenStrategyConfiguredAsync()
{
// Arrange — mock strategy that excludes the first included non-system group
Mock<ICompactionStrategy> mockStrategy = new();
mockStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
group.IsExcluded = true;
group.ExcludeReason = "Mock compaction";
break;
}
}
})
.ReturnsAsync(true);
InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
{
CompactionStrategy = mockStrategy.Object,
});
AgentSession session = CreateMockSession();
// Pre-populate with some messages
List<ChatMessage> existingMessages =
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
];
provider.SetMessages(session, existingMessages);
// Invoke the store flow with additional messages
List<ChatMessage> requestMessages =
[
new ChatMessage(ChatRole.User, "Second"),
];
List<ChatMessage> responseMessages =
[
new ChatMessage(ChatRole.Assistant, "Response 2"),
];
ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages);
// Act
await provider.InvokedAsync(context);
// Assert - compaction should have removed one group
List<ChatMessage> storedMessages = provider.GetMessages(session);
Assert.Equal(3, storedMessages.Count);
mockStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StoreChatHistoryAsync_DoesNotCompact_WhenNoStrategyAsync()
{
// Arrange
InMemoryChatHistoryProvider provider = new();
AgentSession session = CreateMockSession();
List<ChatMessage> requestMessages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
List<ChatMessage> responseMessages =
[
new ChatMessage(ChatRole.Assistant, "Hi!"),
];
ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages);
// Act
await provider.InvokedAsync(context);
// Assert - all messages should be stored
List<ChatMessage> storedMessages = provider.GetMessages(session);
Assert.Equal(2, storedMessages.Count);
}
[Fact]
public async Task CompactStorageAsync_CompactsStoredMessagesAsync()
{
// Arrange — mock strategy that excludes the two oldest non-system groups
Mock<ICompactionStrategy> mockStrategy = new();
mockStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
})
.ReturnsAsync(true);
InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
{
CompactionStrategy = mockStrategy.Object,
});
AgentSession session = CreateMockSession();
provider.SetMessages(session,
[
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 provider.CompactStorageAsync(session);
// Assert
Assert.True(result);
List<ChatMessage> messages = provider.GetMessages(session);
Assert.Equal(2, messages.Count);
mockStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactStorageAsync_UsesProvidedStrategy_OverDefaultAsync()
{
// Arrange
Mock<ICompactionStrategy> defaultStrategy = new();
Mock<ICompactionStrategy> overrideStrategy = new();
overrideStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
// Exclude all but the last group
for (int i = 0; i < groups.Groups.Count - 1; i++)
{
groups.Groups[i].IsExcluded = true;
}
})
.ReturnsAsync(true);
InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
{
CompactionStrategy = defaultStrategy.Object,
});
AgentSession session = CreateMockSession();
provider.SetMessages(session,
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.User, "Third"),
]);
// Act
bool result = await provider.CompactStorageAsync(session, overrideStrategy.Object);
// Assert
Assert.True(result);
List<ChatMessage> messages = provider.GetMessages(session);
Assert.Single(messages);
Assert.Equal("Third", messages[0].Text);
// Verify the override was used, not the default
overrideStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
defaultStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task CompactStorageAsync_Throws_WhenNoStrategyAvailableAsync()
{
// Arrange
InMemoryChatHistoryProvider provider = new();
AgentSession session = CreateMockSession();
// Act & Assert
await Assert.ThrowsAsync<System.InvalidOperationException>(
() => provider.CompactStorageAsync(session));
}
[Fact]
public async Task CompactStorageAsync_WithCustomStrategy_AppliesCustomLogicAsync()
{
// Arrange
Mock<ICompactionStrategy> mockStrategy = new();
mockStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
// Exclude all user groups
foreach (MessageGroup group in groups.Groups)
{
if (group.Kind == MessageGroupKind.User)
{
group.IsExcluded = true;
}
}
})
.ReturnsAsync(true);
InMemoryChatHistoryProvider provider = new();
AgentSession session = CreateMockSession();
provider.SetMessages(session,
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "User message"),
new ChatMessage(ChatRole.Assistant, "Response"),
]);
// Act
bool result = await provider.CompactStorageAsync(session, mockStrategy.Object);
// Assert
Assert.True(result);
List<ChatMessage> messages = provider.GetMessages(session);
Assert.Equal(2, messages.Count);
Assert.Equal(ChatRole.System, messages[0].Role);
Assert.Equal(ChatRole.Assistant, messages[1].Role);
}
}
@@ -0,0 +1,524 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="MessageGroups"/> class.
/// </summary>
public class MessageGroupsTests
{
[Fact]
public void Create_EmptyList_ReturnsEmptyGroups()
{
// Arrange
List<ChatMessage> messages = [];
// Act
MessageGroups groups = MessageGroups.Create(messages);
// Assert
Assert.Empty(groups.Groups);
}
[Fact]
public void Create_SystemMessage_CreatesSystemGroup()
{
// Arrange
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.System, "You are helpful."),
];
// Act
MessageGroups groups = MessageGroups.Create(messages);
// Assert
Assert.Single(groups.Groups);
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
Assert.Single(groups.Groups[0].Messages);
}
[Fact]
public void Create_UserMessage_CreatesUserGroup()
{
// Arrange
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
// Act
MessageGroups groups = MessageGroups.Create(messages);
// Assert
Assert.Single(groups.Groups);
Assert.Equal(MessageGroupKind.User, groups.Groups[0].Kind);
}
[Fact]
public void Create_AssistantTextMessage_CreatesAssistantTextGroup()
{
// Arrange
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.Assistant, "Hi there!"),
];
// Act
MessageGroups groups = MessageGroups.Create(messages);
// Assert
Assert.Single(groups.Groups);
Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[0].Kind);
}
[Fact]
public void Create_ToolCallWithResults_CreatesAtomicToolCallGroup()
{
// 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
MessageGroups groups = MessageGroups.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 Create_MixedConversation_GroupsCorrectly()
{
// 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
MessageGroups groups = MessageGroups.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 Create_MultipleToolResults_GroupsAllWithAssistant()
{
// 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
MessageGroups groups = MessageGroups.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 GetIncludedMessages_ExcludesMarkedGroups()
{
// Arrange
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageGroups groups = MessageGroups.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 GetAllMessages_IncludesExcludedGroups()
{
// Arrange
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
MessageGroups groups = MessageGroups.Create([msg1, msg2]);
groups.Groups[0].IsExcluded = true;
// Act
List<ChatMessage> all = [.. groups.GetAllMessages()];
// Assert
Assert.Equal(2, all.Count);
}
[Fact]
public void IncludedGroupCount_ReflectsExclusions()
{
// Arrange
MessageGroups groups = MessageGroups.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 Create_SummaryMessage_CreatesSummaryGroup()
{
// Arrange
ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts...");
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
List<ChatMessage> messages = [summaryMessage];
// Act
MessageGroups groups = MessageGroups.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 Create_SummaryAmongOtherMessages_GroupsCorrectly()
{
// 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
MessageGroups groups = MessageGroups.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 MessageGroup_StoresPassedCounts()
{
// 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 MessageGroup_MessagesAreImmutable()
{
// 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 Create_ComputesByteCount_Utf8()
{
// Arrange — "Hello" is 5 UTF-8 bytes
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Assert
Assert.Equal(5, groups.Groups[0].ByteCount);
}
[Fact]
public void Create_ComputesByteCount_MultiByteChars()
{
// Arrange — "café" has a multi-byte 'é' (2 bytes in UTF-8) → 5 bytes total
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "café")]);
// Assert
Assert.Equal(5, groups.Groups[0].ByteCount);
}
[Fact]
public void Create_ComputesByteCount_MultipleMessagesInGroup()
{
// 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");
MessageGroups groups = MessageGroups.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 Create_DefaultTokenCount_IsHeuristic()
{
// Arrange — "Hello world test data!" = 22 UTF-8 bytes → 22 / 4 = 5 estimated tokens
MessageGroups groups = MessageGroups.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 Create_NullText_HasZeroCounts()
{
// 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);
MessageGroups groups = MessageGroups.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 TotalAggregates_SumAllGroups()
{
// Arrange
MessageGroups groups = MessageGroups.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 IncludedAggregates_ExcludeMarkedGroups()
{
// Arrange
MessageGroups groups = MessageGroups.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 ToolCallGroup_AggregatesAcrossMessages()
{
// 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");
MessageGroups groups = MessageGroups.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 Create_AssignsTurnIndices_SingleTurn()
{
// Arrange — System (no turn), User + Assistant = turn 1
MessageGroups groups = MessageGroups.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 Create_AssignsTurnIndices_MultiTurn()
{
// Arrange — 3 user turns
MessageGroups groups = MessageGroups.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 Create_TurnSpansToolCallGroups()
{
// Arrange — turn 1 includes User, ToolCall, AssistantText
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
MessageGroups groups = MessageGroups.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 GetTurnGroups_ReturnsGroupsForSpecificTurn()
{
// Arrange
MessageGroups groups = MessageGroups.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 IncludedTurnCount_ReflectsExclusions()
{
// Arrange — 2 turns, exclude all groups in turn 1
MessageGroups groups = MessageGroups.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 TotalTurnCount_ZeroWhenNoUserMessages()
{
// Arrange — only system messages
MessageGroups groups = MessageGroups.Create(
[
new ChatMessage(ChatRole.System, "System."),
]);
// Assert
Assert.Equal(0, groups.TotalTurnCount);
Assert.Equal(0, groups.IncludedTurnCount);
}
[Fact]
public void IncludedTurnCount_PartialExclusion_StillCountsTurn()
{
// Arrange — turn 1 has 2 groups, only one excluded
MessageGroups groups = MessageGroups.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);
}
}
@@ -0,0 +1,282 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
public class PipelineCompactionStrategyTests
{
[Fact]
public async Task CompactAsync_ExecutesAllStrategiesInOrder()
{
// Arrange
List<string> executionOrder = [];
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback(() => executionOrder.Add("first"))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback(() => executionOrder.Add("second"))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object);
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert
Assert.Equal(["first", "second"], executionOrder);
}
[Fact]
public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompacts()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object);
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompacts()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object);
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabled()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object);
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_StopsEarly_WhenTargetReached()
{
// Arrange — first strategy reduces to target
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
// Exclude the first group to bring count down
groups.Groups[0].IsExcluded = true;
})
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object)
{
EarlyStop = true,
TargetIncludedGroupCount = 2,
};
MessageGroups groups = MessageGroups.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — strategy2 should not have been called
Assert.True(result);
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task CompactAsync_DoesNotStopEarly_WhenTargetNotReached()
{
// Arrange — first strategy does NOT bring count to target
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object)
{
EarlyStop = true,
TargetIncludedGroupCount = 1,
};
MessageGroups groups = MessageGroups.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.User, "Third"),
]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called since target was never reached
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_EarlyStopIgnored_WhenNoTargetSet()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object)
{
EarlyStop = true,
// TargetIncludedGroupCount is null
};
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies called because no target to check against
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_ComposesStrategies_EndToEnd()
{
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
Mock<ICompactionStrategy> phase1 = new();
phase1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
})
.ReturnsAsync(true);
Mock<ICompactionStrategy> phase2 = new();
phase2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
})
.ReturnsAsync(true);
PipelineCompactionStrategy pipeline = new(phase1.Object, phase2.Object);
MessageGroups groups = MessageGroups.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);
phase1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
phase2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync()
{
// Arrange
PipelineCompactionStrategy pipeline = new(new List<ICompactionStrategy>());
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
}
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
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 CompactAsync_BelowLimit_ReturnsFalseAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 5);
MessageGroups groups = MessageGroups.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 CompactAsync_AtLimit_ReturnsFalseAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2);
MessageGroups groups = MessageGroups.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 CompactAsync_ExceedsLimit_ExcludesOldestGroupsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2);
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
ChatMessage msg3 = new(ChatRole.User, "Second");
ChatMessage msg4 = new(ChatRole.Assistant, "Response 2");
MessageGroups groups = MessageGroups.Create([msg1, msg2, msg3, msg4]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
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 CompactAsync_PreservesSystemMessages_WhenEnabledAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: true);
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]);
// 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 should be excluded
Assert.True(groups.Groups[1].IsExcluded);
Assert.True(groups.Groups[2].IsExcluded);
// Most recent should remain
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsync_DoesNotPreserveSystemMessages_WhenDisabledAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: false);
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// System message should be excluded (oldest)
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 CompactAsync_PreservesToolCallGroupAtomicityAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
MessageGroups groups = MessageGroups.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 CompactAsync_SetsExcludeReasonAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
MessageGroups groups = MessageGroups.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 CompactAsync_SkipsAlreadyExcludedGroupsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
MessageGroups groups = MessageGroups.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
}
}