mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feat/durable_task
This commit is contained in:
@@ -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 -->
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a CompactionProvider with a compaction pipeline
|
||||
// as an AIContextProvider for an agent's in-run context management. The pipeline chains multiple
|
||||
// compaction strategies from gentle to aggressive:
|
||||
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
|
||||
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
|
||||
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
|
||||
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Create a chat client for the agent and a separate one for the summarization strategy.
|
||||
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
|
||||
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
|
||||
// Define a tool the agent can use, so we can see tool-result compaction in action.
|
||||
[Description("Look up the current price of a product by name.")]
|
||||
static string LookupPrice([Description("The product name to look up.")] string productName) =>
|
||||
productName.ToUpperInvariant() switch
|
||||
{
|
||||
"LAPTOP" => "The laptop costs $999.99.",
|
||||
"KEYBOARD" => "The keyboard costs $79.99.",
|
||||
"MOUSE" => "The mouse costs $29.99.",
|
||||
_ => $"Sorry, I don't have pricing for '{productName}'."
|
||||
};
|
||||
|
||||
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
|
||||
PipelineCompactionStrategy compactionPipeline =
|
||||
new(// 1. Gentle: collapse old tool-call groups into short summaries
|
||||
new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(7)),
|
||||
|
||||
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
|
||||
|
||||
// 3. Aggressive: keep only the last N user turns and their responses
|
||||
new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
|
||||
|
||||
// 4. Emergency: drop oldest groups until under the token budget
|
||||
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
|
||||
|
||||
// Create the agent with a CompactionProvider that uses the compaction pipeline.
|
||||
AIAgent agent =
|
||||
agentChatClient
|
||||
.AsBuilder()
|
||||
// Note: Adding the CompactionProvider at the builder level means it will be applied to all agents
|
||||
// built from this builder and will manage context for both agent messages and tool calls.
|
||||
.UseAIContextProviders(new CompactionProvider(compactionPipeline))
|
||||
.BuildAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ShoppingAssistant",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful, but long winded, shopping assistant.
|
||||
Help the user look up prices and compare products.
|
||||
When responding, Be sure to be extra descriptive and use as
|
||||
many words as possible without sounding ridiculous.
|
||||
""",
|
||||
Tools = [AIFunctionFactory.Create(LookupPrice)]
|
||||
},
|
||||
// Note: AIContextProviders may be specified here instead of ChatClientBuilder.UseAIContextProviders.
|
||||
// Specifying compaction at the agent level skips compaction in the function calling loop.
|
||||
//AIContextProviders = [new CompactionProvider(compactionPipeline)]
|
||||
});
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Helper to print chat history size
|
||||
void PrintChatHistory()
|
||||
{
|
||||
if (session.TryGetInMemoryChatHistory(out var history))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"\n[Messages: #{history.Count}]\n");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
// Run a multi-turn conversation with tool calls to exercise the pipeline.
|
||||
string[] prompts =
|
||||
[
|
||||
"What's the price of a laptop?",
|
||||
"How about a keyboard?",
|
||||
"And a mouse?",
|
||||
"Which product is the cheapest?",
|
||||
"Can you compare the laptop and the keyboard for me?",
|
||||
"What was the first product I asked about?",
|
||||
"Thank you!",
|
||||
];
|
||||
|
||||
foreach (string prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("\n[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("\n[Agent] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(await agent.RunAsync(prompt, session));
|
||||
|
||||
PrintChatHistory();
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Compaction Pipeline
|
||||
|
||||
This sample demonstrates how to use a `CompactionProvider` with a `PipelineCompactionStrategy` to manage long conversation histories in a token-efficient way. The pipeline chains four compaction strategies, ordered from gentle to aggressive, so that the least disruptive strategy runs first and more aggressive strategies only activate when necessary.
|
||||
|
||||
## What This Sample Shows
|
||||
|
||||
- **`CompactionProvider`** — an `AIContextProvider` that applies a compaction strategy before each agent invocation, keeping only the most relevant messages within the model's context window
|
||||
- **`PipelineCompactionStrategy`** — chains multiple compaction strategies into an ordered pipeline; each strategy evaluates its own trigger independently and operates on the output of the previous one
|
||||
- **`ToolResultCompactionStrategy`** — collapses older tool-call groups into concise inline summaries, activated by a message-count trigger
|
||||
- **`SummarizationCompactionStrategy`** — uses an LLM to compress older conversation spans into a single summary message, activated by a token-count trigger
|
||||
- **`SlidingWindowCompactionStrategy`** — retains only the most recent N user turns and their responses, activated by a turn-count trigger
|
||||
- **`TruncationCompactionStrategy`** — emergency backstop that drops the oldest groups until the conversation fits within a hard token budget
|
||||
- **`CompactionTriggers`** — factory methods (`MessagesExceed`, `TokensExceed`, `TurnsExceed`, `GroupsExceed`, `HasToolCalls`, `All`, `Any`) that control when each strategy activates
|
||||
|
||||
## Concepts
|
||||
|
||||
### Message groups
|
||||
|
||||
The compaction engine organizes messages into atomic *groups* that are treated as indivisible units during compaction. A group is either:
|
||||
|
||||
| Group kind | Contents |
|
||||
|---|---|
|
||||
| `System` | System prompt message(s) |
|
||||
| `User` | A single user message |
|
||||
| `ToolCall` | One assistant message with tool calls + the matching tool result messages |
|
||||
| `AssistantText` | A single assistant text-only message |
|
||||
| `Summary` | One or more messages summarizing earlier conversation spans, produced by compaction strategies |
|
||||
|
||||
`Summary` groups (`CompactionGroupKind.Summary`) are created by compaction strategies (for example, `SummarizationCompactionStrategy`) and do not originate directly from user or assistant messages.
|
||||
Strategies exclude entire groups rather than individual messages, preserving the tool-call/result pairing required by most model APIs.
|
||||
|
||||
### Compaction triggers
|
||||
|
||||
A `CompactionTrigger` is a predicate evaluated against the current `MessageIndex`. When the trigger fires, the strategy performs compaction; when it does not fire, the strategy is skipped. Available triggers are:
|
||||
|
||||
| Trigger | Activates when… |
|
||||
|---|---|
|
||||
| `CompactionTriggers.Always` | Always (unconditional) |
|
||||
| `CompactionTriggers.Never` | Never (disabled) |
|
||||
| `CompactionTriggers.MessagesExceed(n)` | Included message count > n |
|
||||
| `CompactionTriggers.TokensExceed(n)` | Included token count > n |
|
||||
| `CompactionTriggers.TurnsExceed(n)` | Included user-turn count > n |
|
||||
| `CompactionTriggers.GroupsExceed(n)` | Included group count > n |
|
||||
| `CompactionTriggers.HasToolCalls()` | At least one included tool-call group exists |
|
||||
| `CompactionTriggers.All(...)` | All supplied triggers fire (logical AND) |
|
||||
| `CompactionTriggers.Any(...)` | Any supplied trigger fires (logical OR) |
|
||||
|
||||
### Pipeline ordering
|
||||
|
||||
Order strategies from **least aggressive** to **most aggressive**. The pipeline runs every strategy whose trigger is met. Earlier strategies reduce the conversation gently so that later, more destructive strategies may not need to activate at all.
|
||||
|
||||
```
|
||||
1. ToolResultCompactionStrategy – gentle: replaces verbose tool results with a short label
|
||||
2. SummarizationCompactionStrategy – moderate: LLM-summarizes older turns
|
||||
3. SlidingWindowCompactionStrategy – aggressive: drops turns beyond the window
|
||||
4. TruncationCompactionStrategy – emergency: hard token-budget enforcement
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure OpenAI service endpoint and model deployment
|
||||
- Azure CLI installed and authenticated
|
||||
|
||||
**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
The sample runs a seven-turn shopping-assistant conversation with tool calls. After each turn it prints the full message count so you can observe the pipeline compaction doesn't alter the source conversation.
|
||||
|
||||
Each of the four compaction strategies has a deliberately low threshold so that it activates during the short demonstration conversation. In a production scenario you would raise the thresholds to match your model's context window and cost requirements.
|
||||
|
||||
## Customizing the Pipeline
|
||||
|
||||
### Using a single strategy
|
||||
|
||||
If you only need one compaction strategy, pass it directly to `CompactionProvider` without wrapping it in a pipeline:
|
||||
|
||||
```csharp
|
||||
CompactionProvider provider =
|
||||
new(new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(20)));
|
||||
```
|
||||
|
||||
### Ad-hoc compaction outside the provider pipeline
|
||||
|
||||
`CompactionProvider.CompactAsync` applies a strategy to an arbitrary list of messages without an active agent session:
|
||||
|
||||
```csharp
|
||||
IEnumerable<ChatMessage> compacted = await CompactionProvider.CompactAsync(
|
||||
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(8000)),
|
||||
existingMessages);
|
||||
```
|
||||
|
||||
### Using a different model for summarization
|
||||
|
||||
The `SummarizationCompactionStrategy` accepts any `IChatClient`. Use a smaller, cheaper model to reduce summarization cost:
|
||||
|
||||
```csharp
|
||||
IChatClient summarizerChatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient();
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(4000))
|
||||
```
|
||||
|
||||
### Registering through `ChatClientAgentOptions`
|
||||
|
||||
`CompactionProvider` can also be specified directly on `ChatClientAgentOptions` instead of calling `UseAIContextProviders` on the `ChatClientBuilder`:
|
||||
|
||||
```csharp
|
||||
AIAgent agent = agentChatClient
|
||||
.AsBuilder()
|
||||
.BuildAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [new CompactionProvider(compactionPipeline)]
|
||||
});
|
||||
```
|
||||
|
||||
This places the compaction provider at the agent level instead of the chat client level, which allows you to use different compaction strategies for different agents that share the same chat client.
|
||||
|
||||
> Note: In this mode the `CompactionProvider` is not engaged during the tool calling loop. Agent-level `AIContextProviders` run before chat history is stored, so any synthetic summary messages produced by `CompactionProvider` can become part of the persisted history when using `ChatHistoryProvider`. If you want to compact only the request context while preserving the original stored history, register `CompactionProvider` on the `ChatClientBuilder` via `UseAIContextProviders(...)` instead of on `ChatClientAgentOptions`.
|
||||
@@ -44,6 +44,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|
||||
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|
||||
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|
||||
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
@@ -79,20 +79,21 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
|
||||
public void SetMessages(AgentSession? session, List<ChatMessage> messages)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
Throw.IfNull(messages);
|
||||
|
||||
var state = this._sessionState.GetOrInitializeState(session);
|
||||
State state = this._sessionState.GetOrInitializeState(session);
|
||||
state.Messages = messages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
|
||||
{
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
// Apply pre-retrieval reduction if configured
|
||||
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return state.Messages;
|
||||
@@ -101,7 +102,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
// Add request and response messages to the provider
|
||||
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
|
||||
@@ -109,10 +110,16 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
{
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
// Apply pre-write reduction strategy if configured
|
||||
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
state.Messages = [.. await reducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -55,7 +55,7 @@ public static class ChatClientExtensions
|
||||
|
||||
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
_ = chatBuilder.Use((innerClient, services) =>
|
||||
chatBuilder.Use((innerClient, services) =>
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Content-based equality comparison for <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
internal static class ChatMessageContentEquality
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether two <see cref="ChatMessage"/> instances represent the same message by content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When both messages define a <see cref="ChatMessage.MessageId"/>, identity is determined solely
|
||||
/// by that identifier. Otherwise, the comparison falls through to <see cref="ChatMessage.Role"/>,
|
||||
/// <see cref="ChatMessage.AuthorName"/>, and each item in <see cref="ChatMessage.Contents"/>.
|
||||
/// </remarks>
|
||||
internal static bool ContentEquals(this ChatMessage? message, ChatMessage? other)
|
||||
{
|
||||
if (ReferenceEquals(message, other))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message is null || other is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A matching MessageId is sufficient.
|
||||
if (message.MessageId is not null && other.MessageId is not null)
|
||||
{
|
||||
return string.Equals(message.MessageId, other.MessageId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (message.Role != other.Role)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(message.AuthorName, other.AuthorName, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ContentsEqual(message.Contents, other.Contents);
|
||||
}
|
||||
|
||||
private static bool ContentsEqual(IList<AIContent> left, IList<AIContent> right)
|
||||
{
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < left.Count; i++)
|
||||
{
|
||||
if (!ContentItemEquals(left[i], right[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ContentItemEquals(AIContent left, AIContent right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left.GetType() != right.GetType())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return (left, right) switch
|
||||
{
|
||||
(TextContent a, TextContent b) => TextContentEquals(a, b),
|
||||
(TextReasoningContent a, TextReasoningContent b) => TextReasoningContentEquals(a, b),
|
||||
(DataContent a, DataContent b) => DataContentEquals(a, b),
|
||||
(UriContent a, UriContent b) => UriContentEquals(a, b),
|
||||
(ErrorContent a, ErrorContent b) => ErrorContentEquals(a, b),
|
||||
(FunctionCallContent a, FunctionCallContent b) => FunctionCallContentEquals(a, b),
|
||||
(FunctionResultContent a, FunctionResultContent b) => FunctionResultContentEquals(a, b),
|
||||
(HostedFileContent a, HostedFileContent b) => HostedFileContentEquals(a, b),
|
||||
(AIContent a, AIContent b) => a.GetType() == b.GetType(),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TextContentEquals(TextContent a, TextContent b) =>
|
||||
string.Equals(a.Text, b.Text, StringComparison.Ordinal);
|
||||
|
||||
private static bool TextReasoningContentEquals(TextReasoningContent a, TextReasoningContent b) =>
|
||||
string.Equals(a.Text, b.Text, StringComparison.Ordinal) &&
|
||||
string.Equals(a.ProtectedData, b.ProtectedData, StringComparison.Ordinal);
|
||||
|
||||
private static bool DataContentEquals(DataContent a, DataContent b) =>
|
||||
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
|
||||
string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
|
||||
a.Data.Span.SequenceEqual(b.Data.Span);
|
||||
|
||||
private static bool UriContentEquals(UriContent a, UriContent b) =>
|
||||
Equals(a.Uri, b.Uri) &&
|
||||
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal);
|
||||
|
||||
private static bool ErrorContentEquals(ErrorContent a, ErrorContent b) =>
|
||||
string.Equals(a.Message, b.Message, StringComparison.Ordinal) &&
|
||||
string.Equals(a.ErrorCode, b.ErrorCode, StringComparison.Ordinal) &&
|
||||
Equals(a.Details, b.Details);
|
||||
|
||||
private static bool FunctionCallContentEquals(FunctionCallContent a, FunctionCallContent b) =>
|
||||
string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
|
||||
string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
|
||||
ArgumentsEqual(a.Arguments, b.Arguments);
|
||||
|
||||
private static bool FunctionResultContentEquals(FunctionResultContent a, FunctionResultContent b) =>
|
||||
string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
|
||||
Equals(a.Result, b.Result);
|
||||
|
||||
private static bool ArgumentsEqual(IDictionary<string, object?>? left, IDictionary<string, object?>? right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left is null || right is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, object?> entry in left)
|
||||
{
|
||||
if (!right.TryGetValue(entry.Key, out object? value) || !Equals(entry.Value, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HostedFileContentEquals(HostedFileContent a, HostedFileContent b) =>
|
||||
string.Equals(a.FileId, b.FileId, StringComparison.Ordinal) &&
|
||||
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
|
||||
string.Equals(a.Name, b.Name, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that delegates to an <see cref="IChatReducer"/> to reduce the conversation's
|
||||
/// included messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy bridges the <see cref="IChatReducer"/> abstraction from <c>Microsoft.Extensions.AI</c>
|
||||
/// into the compaction pipeline. It collects the currently included messages from the
|
||||
/// <see cref="CompactionMessageIndex"/>, passes them to the reducer, and rebuilds the index from the
|
||||
/// reduced message list when the reducer produces fewer messages.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> controls when reduction is attempted.
|
||||
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or message thresholds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use this strategy when you have an existing <see cref="IChatReducer"/> implementation
|
||||
/// (such as <c>MessageCountingChatReducer</c>) and want to apply it as part of a
|
||||
/// <see cref="CompactionStrategy"/> pipeline or as an in-run compaction strategy.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class ChatReducerCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatReducerCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatReducer">
|
||||
/// The <see cref="IChatReducer"/> that performs the message reduction.
|
||||
/// </param>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger trigger)
|
||||
: base(trigger)
|
||||
{
|
||||
this.ChatReducer = Throw.IfNull(chatReducer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat reducer used to reduce messages.
|
||||
/// </summary>
|
||||
public IChatReducer ChatReducer { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// No need to short-circuit on empty conversations, this is handled by <see cref="CompactionStrategy.CompactAsync"/>.
|
||||
List<ChatMessage> includedMessages = [.. index.GetIncludedMessages()];
|
||||
|
||||
IEnumerable<ChatMessage> reduced = await this.ChatReducer.ReduceAsync(includedMessages, cancellationToken).ConfigureAwait(false);
|
||||
IList<ChatMessage> reducedMessages = reduced as IList<ChatMessage> ?? [.. reduced];
|
||||
|
||||
if (reducedMessages.Count >= includedMessages.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rebuild the index from the reduced messages
|
||||
CompactionMessageIndex rebuilt = CompactionMessageIndex.Create(reducedMessages, index.Tokenizer);
|
||||
index.Groups.Clear();
|
||||
foreach (CompactionMessageGroup group in rebuilt.Groups)
|
||||
{
|
||||
index.Groups.Add(group);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of a <see cref="CompactionMessageGroup"/>.
|
||||
/// </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>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public enum CompactionGroupKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A system message group containing one or more system messages.
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// A user message group containing a single user message.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An assistant message group containing a single assistant text response (no tool calls).
|
||||
/// </summary>
|
||||
AssistantText,
|
||||
|
||||
/// <summary>
|
||||
/// An atomic tool call group containing an assistant message with tool calls
|
||||
/// followed by the corresponding tool result messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This group must be treated as an atomic unit during compaction. Removing the assistant
|
||||
/// message without its tool results (or vice versa) will cause LLM API errors.
|
||||
/// </remarks>
|
||||
ToolCall,
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names
|
||||
/// <summary>
|
||||
/// A summary message group produced by a compaction strategy (e.g., <c>SummarizationCompactionStrategy</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Summary groups replace previously compacted messages with a condensed representation.
|
||||
/// They are identified by the <see cref="CompactionMessageGroup.SummaryPropertyKey"/> metadata entry
|
||||
/// on the underlying <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </remarks>
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
Summary,
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
#pragma warning disable SYSLIB1006 // Multiple logging methods cannot use the same event id within a class
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for logging compaction diagnostics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
|
||||
/// generate logging code at compile time to achieve optimized code.
|
||||
/// </remarks>
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static partial class CompactionLogMessages
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs when compaction is skipped because the trigger condition was not met.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Trace,
|
||||
Message = "Compaction skipped for {StrategyName}: trigger condition not met or insufficient groups.")]
|
||||
public static partial void LogCompactionSkipped(
|
||||
this ILogger logger,
|
||||
string strategyName);
|
||||
|
||||
/// <summary>
|
||||
/// Logs compaction completion with before/after metrics.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Compaction completed: {StrategyName} in {DurationMs}ms — Messages {BeforeMessages}→{AfterMessages}, Groups {BeforeGroups}→{AfterGroups}, Tokens {BeforeTokens}→{AfterTokens}")]
|
||||
public static partial void LogCompactionCompleted(
|
||||
this ILogger logger,
|
||||
string strategyName,
|
||||
long durationMs,
|
||||
int beforeMessages,
|
||||
int afterMessages,
|
||||
int beforeGroups,
|
||||
int afterGroups,
|
||||
int beforeTokens,
|
||||
int afterTokens);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when the compaction provider skips compaction.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Trace,
|
||||
Message = "CompactionProvider skipped: {Reason}.")]
|
||||
public static partial void LogCompactionProviderSkipped(
|
||||
this ILogger logger,
|
||||
string reason);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when the compaction provider begins applying a compaction strategy.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "CompactionProvider applying compaction to {MessageCount} messages using {StrategyName}.")]
|
||||
public static partial void LogCompactionProviderApplying(
|
||||
this ILogger logger,
|
||||
int messageCount,
|
||||
string strategyName);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when the compaction provider has applied compaction with result metrics.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "CompactionProvider compaction applied: messages {BeforeMessages}→{AfterMessages}.")]
|
||||
public static partial void LogCompactionProviderApplied(
|
||||
this ILogger logger,
|
||||
int beforeMessages,
|
||||
int afterMessages);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when a summarization LLM call is starting.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Summarization starting for {GroupCount} groups ({MessageCount} messages) using {ChatClientType}.")]
|
||||
public static partial void LogSummarizationStarting(
|
||||
this ILogger logger,
|
||||
int groupCount,
|
||||
int messageCount,
|
||||
string chatClientType);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when a summarization LLM call has completed.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Summarization completed: summary length {SummaryLength} characters, inserted at index {InsertIndex}.")]
|
||||
public static partial void LogSummarizationCompleted(
|
||||
this ILogger logger,
|
||||
int summaryLength,
|
||||
int insertIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Logs when a summarization LLM call fails and groups are restored.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Summarization failed for {GroupCount} groups; restoring excluded groups and continuing without compaction. Error: {ErrorMessage}")]
|
||||
public static partial void LogSummarizationFailed(
|
||||
this ILogger logger,
|
||||
int groupCount,
|
||||
string errorMessage);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
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="CompactionGroupKind.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="CompactionMessageIndex"/> can efficiently aggregate totals across all or only included groups.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class CompactionMessageGroup
|
||||
{
|
||||
/// <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="CompactionGroupKind.Summary"/> by <see cref="CompactionMessageIndex.Create"/>.
|
||||
/// </remarks>
|
||||
public static readonly string SummaryPropertyKey = "_is_summary";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionMessageGroup"/> 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 user turn this group belongs to, or <see langword="null"/> for <see cref="CompactionGroupKind.System"/>.
|
||||
/// </param>
|
||||
[JsonConstructor]
|
||||
internal CompactionMessageGroup(CompactionGroupKind 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 CompactionGroupKind 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 user turn index this group belongs to, or <see langword="null"/> for groups
|
||||
/// that precede the first user message (e.g., system messages). A turn index of 0
|
||||
/// corresponds with any non-system message that precedes the first user message,
|
||||
/// turn index 1 corresponds with the first user message and its subsequent non-user
|
||||
/// messages, and so on...
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A turn starts with a <see cref="CompactionGroupKind.User"/> group and includes all subsequent
|
||||
/// non-user, non-system groups until the next user group or end of conversation. System messages
|
||||
/// (<see cref="CompactionGroupKind.System"/>) are always assigned a <see langword="null"/> turn index
|
||||
/// since they never belong to a user turn.
|
||||
/// </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="CompactionMessageIndex.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,529 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A collection of <see cref="CompactionMessageGroup"/> instances and derived metrics based on a flat list of <see cref="ChatMessage"/> objects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="CompactionMessageIndex"/> provides structural grouping of messages into logical <see cref="CompactionMessageGroup"/> units. Individual
|
||||
/// groups can be marked as excluded without being removed, allowing compaction strategies to toggle visibility while preserving
|
||||
/// the full history for diagnostics or storage. Metrics are provided both including and excluding excluded groups,
|
||||
/// allowing strategies to make informed decisions based on the impact of potential exclusions.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class CompactionMessageIndex
|
||||
{
|
||||
private int _currentTurn;
|
||||
private ChatMessage? _lastProcessedMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of message groups in this collection.
|
||||
/// </summary>
|
||||
public IList<CompactionMessageGroup> 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="CompactionMessageIndex"/> 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 CompactionMessageIndex(IList<CompactionMessageGroup> groups, Tokenizer? tokenizer = null)
|
||||
{
|
||||
this.Groups = Throw.IfNull(groups, nameof(groups));
|
||||
this.Tokenizer = tokenizer;
|
||||
|
||||
// Restore turn counter and last processed message from the groups
|
||||
for (int index = groups.Count - 1; index >= 0; --index)
|
||||
{
|
||||
if (this._lastProcessedMessage is null && this.Groups[index].Kind != CompactionGroupKind.Summary)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> groupMessages = this.Groups[index].Messages;
|
||||
this._lastProcessedMessage = groupMessages[^1];
|
||||
}
|
||||
|
||||
if (this.Groups[index].TurnIndex.HasValue)
|
||||
{
|
||||
this._currentTurn = this.Groups[index].TurnIndex!.Value;
|
||||
|
||||
// Both values restored — no need to keep scanning
|
||||
if (this._lastProcessedMessage is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="CompactionMessageIndex"/> 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="CompactionMessageIndex"/> with messages organized into logical groups.</returns>
|
||||
/// <remarks>
|
||||
/// The grouping algorithm:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>System messages become <see cref="CompactionGroupKind.System"/> groups.</description></item>
|
||||
/// <item><description>User messages become <see cref="CompactionGroupKind.User"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages with tool calls, followed by their corresponding tool result messages, become <see cref="CompactionGroupKind.ToolCall"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages marked with <see cref="CompactionMessageGroup.SummaryPropertyKey"/> become <see cref="CompactionGroupKind.Summary"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages without tool calls become <see cref="CompactionGroupKind.AssistantText"/> groups.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static CompactionMessageIndex Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
|
||||
{
|
||||
CompactionMessageIndex instance = new([], tokenizer);
|
||||
instance.AppendFromMessages(messages, 0);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incrementally updates the groups with new messages from the conversation.
|
||||
/// </summary>
|
||||
/// <param name="allMessages">
|
||||
/// The full list of messages for the conversation. This must be the same list (or a replacement with the same
|
||||
/// prefix) that was used to create or last update this instance.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Uses equality on the last processed message to detect changes. Only the messages after that position are
|
||||
/// processed and appended as new groups. Existing groups and their compaction state (exclusions) are preserved.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the last processed message is not found (e.g., the message list was replaced entirely
|
||||
/// or a sliding window shifted past it), all groups are cleared and rebuilt from scratch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the last message in <paramref name="allMessages"/> matches the last
|
||||
/// processed message, no work is performed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void Update(IList<ChatMessage> allMessages)
|
||||
{
|
||||
if (allMessages.Count == 0)
|
||||
{
|
||||
this.Groups.Clear();
|
||||
this._currentTurn = 0;
|
||||
this._lastProcessedMessage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// If the last message is unchanged and the list hasn't shrunk, there is nothing new to process.
|
||||
if (this._lastProcessedMessage is not null &&
|
||||
allMessages.Count >= this.RawMessageCount &&
|
||||
allMessages[allMessages.Count - 1].ContentEquals(this._lastProcessedMessage))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk backwards to locate where we left off.
|
||||
int foundIndex = -1;
|
||||
if (this._lastProcessedMessage is not null)
|
||||
{
|
||||
for (int i = allMessages.Count - 1; i >= 0; --i)
|
||||
{
|
||||
if (allMessages[i].ContentEquals(this._lastProcessedMessage))
|
||||
{
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundIndex < 0)
|
||||
{
|
||||
// Last processed message not found — total rebuild.
|
||||
this.Groups.Clear();
|
||||
this._currentTurn = 0;
|
||||
this.AppendFromMessages(allMessages, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard against a sliding window that removed messages from the front:
|
||||
// the number of messages up to (and including) the found position must
|
||||
// match the number of messages already represented by existing groups.
|
||||
if (foundIndex + 1 < this.RawMessageCount)
|
||||
{
|
||||
// Front of the message list was trimmed — rebuild.
|
||||
this.Groups.Clear();
|
||||
this._currentTurn = 0;
|
||||
this.AppendFromMessages(allMessages, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process only the delta messages.
|
||||
this.AppendFromMessages(allMessages, foundIndex + 1);
|
||||
}
|
||||
|
||||
private void AppendFromMessages(IList<ChatMessage> messages, int startIndex)
|
||||
{
|
||||
int index = startIndex;
|
||||
|
||||
while (index < messages.Count)
|
||||
{
|
||||
ChatMessage message = messages[index];
|
||||
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
// System messages are not part of any turn
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.System, [message], this.Tokenizer, turnIndex: null));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.User)
|
||||
{
|
||||
this._currentTurn++;
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.User, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
|
||||
{
|
||||
List<ChatMessage> groupMessages = [message];
|
||||
index++;
|
||||
|
||||
// Collect all subsequent tool result messages and reasoning-only assistant messages
|
||||
while (index < messages.Count &&
|
||||
(messages[index].Role == ChatRole.Tool ||
|
||||
(messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index]))))
|
||||
{
|
||||
groupMessages.Add(messages[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
|
||||
{
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && HasOnlyReasoning(message))
|
||||
{
|
||||
// Reasoning-only assistant messages that precede a tool-call assistant message
|
||||
// are part of the same atomic tool-call group. Look ahead past consecutive
|
||||
// reasoning messages to find a possible tool-call message.
|
||||
int lookahead = index + 1;
|
||||
while (lookahead < messages.Count &&
|
||||
messages[lookahead].Role == ChatRole.Assistant &&
|
||||
HasOnlyReasoning(messages[lookahead]))
|
||||
{
|
||||
lookahead++;
|
||||
}
|
||||
|
||||
if (lookahead < messages.Count && messages[lookahead].Role == ChatRole.Assistant && HasToolCalls(messages[lookahead]))
|
||||
{
|
||||
// Group all reasoning messages + the tool-call message together
|
||||
List<ChatMessage> groupMessages = [];
|
||||
for (int j = index; j <= lookahead; j++)
|
||||
{
|
||||
groupMessages.Add(messages[j]);
|
||||
}
|
||||
|
||||
index = lookahead + 1;
|
||||
|
||||
// Collect all subsequent tool result messages and reasoning-only assistant messages
|
||||
while (index < messages.Count &&
|
||||
(messages[index].Role == ChatRole.Tool ||
|
||||
(messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index]))))
|
||||
{
|
||||
groupMessages.Add(messages[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.Count > 0)
|
||||
{
|
||||
this._lastProcessedMessage = messages[^1];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="CompactionMessageGroup"/> 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="CompactionMessageGroup"/>.</returns>
|
||||
public CompactionMessageGroup InsertGroup(int index, CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Insert(index, group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="CompactionMessageGroup"/> 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="CompactionMessageGroup"/>.</returns>
|
||||
public CompactionMessageGroup AddGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the messages from groups that are not excluded.
|
||||
/// </summary>
|
||||
/// <returns>A list of <see cref="ChatMessage"/> instances from included groups, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetIncludedMessages() =>
|
||||
this.Groups.Where(group => !group.IsExcluded).SelectMany(group => group.Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all messages from all groups, including excluded ones.
|
||||
/// </summary>
|
||||
/// <returns>A list of all <see cref="ChatMessage"/> instances, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetAllMessages() => this.Groups.SelectMany(group => group.Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalGroupCount => this.Groups.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalMessageCount => this.Groups.Sum(group => group.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalByteCount => this.Groups.Sum(group => group.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalTokenCount => this.Groups.Sum(group => group.TokenCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups that are not excluded.
|
||||
/// </summary>
|
||||
public int IncludedGroupCount => this.Groups.Count(group => !group.IsExcluded);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedMessageCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedByteCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedTokenCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of user turns across all groups (including those with excluded groups).
|
||||
/// </summary>
|
||||
public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0);
|
||||
|
||||
/// <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 && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups across all included (non-excluded) groups that are not <see cref="CompactionGroupKind.System"/>.
|
||||
/// </summary>
|
||||
public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of original messages (that are not summaries).
|
||||
/// </summary>
|
||||
public int RawMessageCount => this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all groups that belong to the specified user turn.
|
||||
/// </summary>
|
||||
/// <param name="turnIndex">The desired turn index.</param>
|
||||
/// <returns>The groups belonging to the turn, in order.</returns>
|
||||
public IEnumerable<CompactionMessageGroup> GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the UTF-8 byte count for a set of messages across all content types.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compute byte count for.</param>
|
||||
/// <returns>The total UTF-8 byte count of all message content.</returns>
|
||||
internal static int ComputeByteCount(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
IList<AIContent> contents = messages[i].Contents;
|
||||
for (int j = 0; j < contents.Count; j++)
|
||||
{
|
||||
total += ComputeContentByteCount(contents[j]);
|
||||
}
|
||||
}
|
||||
|
||||
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 content.</returns>
|
||||
/// <remarks>
|
||||
/// Text-bearing content (<see cref="TextContent"/> and <see cref="TextReasoningContent"/>)
|
||||
/// is tokenized directly. All other content types estimate tokens as <c>byteCount / 4</c>.
|
||||
/// </remarks>
|
||||
internal static int ComputeTokenCount(IReadOnlyList<ChatMessage> messages, Tokenizer tokenizer)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
IList<AIContent> contents = messages[i].Contents;
|
||||
for (int j = 0; j < contents.Count; j++)
|
||||
{
|
||||
AIContent content = contents[j];
|
||||
switch (content)
|
||||
{
|
||||
case TextContent text:
|
||||
if (text.Text is { Length: > 0 } t)
|
||||
{
|
||||
total += tokenizer.CountTokens(t);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case TextReasoningContent reasoning:
|
||||
if (reasoning.Text is { Length: > 0 } rt)
|
||||
{
|
||||
total += tokenizer.CountTokens(rt);
|
||||
}
|
||||
|
||||
if (reasoning.ProtectedData is { Length: > 0 } pd)
|
||||
{
|
||||
total += tokenizer.CountTokens(pd);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
total += ComputeContentByteCount(content) / 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static int ComputeContentByteCount(AIContent content)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case TextContent text:
|
||||
return GetStringByteCount(text.Text);
|
||||
|
||||
case TextReasoningContent reasoning:
|
||||
return GetStringByteCount(reasoning.Text) + GetStringByteCount(reasoning.ProtectedData);
|
||||
|
||||
case DataContent data:
|
||||
return data.Data.Length + GetStringByteCount(data.MediaType) + GetStringByteCount(data.Name);
|
||||
|
||||
case UriContent uri:
|
||||
return (uri.Uri is Uri uriValue ? GetStringByteCount(uriValue.OriginalString) : 0) + GetStringByteCount(uri.MediaType);
|
||||
|
||||
case FunctionCallContent call:
|
||||
int callBytes = GetStringByteCount(call.CallId) + GetStringByteCount(call.Name);
|
||||
if (call.Arguments is not null)
|
||||
{
|
||||
foreach (KeyValuePair<string, object?> arg in call.Arguments)
|
||||
{
|
||||
callBytes += GetStringByteCount(arg.Key);
|
||||
callBytes += GetStringByteCount(arg.Value?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
return callBytes;
|
||||
|
||||
case FunctionResultContent result:
|
||||
return GetStringByteCount(result.CallId) + GetStringByteCount(result.Result?.ToString());
|
||||
|
||||
case ErrorContent error:
|
||||
return GetStringByteCount(error.Message) + GetStringByteCount(error.ErrorCode) + GetStringByteCount(error.Details);
|
||||
|
||||
case HostedFileContent file:
|
||||
return GetStringByteCount(file.FileId) + GetStringByteCount(file.MediaType) + GetStringByteCount(file.Name);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetStringByteCount(string? value) =>
|
||||
value is { Length: > 0 } ? Encoding.UTF8.GetByteCount(value) : 0;
|
||||
|
||||
private static CompactionMessageGroup CreateGroup(CompactionGroupKind 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 CompactionMessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
|
||||
}
|
||||
|
||||
private static bool HasToolCalls(ChatMessage message)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasOnlyReasoning(ChatMessage message) =>
|
||||
message.Contents.All(content => content is TextReasoningContent);
|
||||
|
||||
private static bool IsSummaryMessage(ChatMessage message) =>
|
||||
message.AdditionalProperties?.TryGetValue(CompactionMessageGroup.SummaryPropertyKey, out object? value) is true
|
||||
&& value is true;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="AIContextProvider"/> that applies a <see cref="CompactionStrategy"/> to compact
|
||||
/// the message list before each agent invocation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider performs in-run compaction by organizing messages into atomic groups (preserving
|
||||
/// tool-call/result pairings) before applying compaction logic. Only included messages are forwarded
|
||||
/// to the agent's underlying chat client.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionProvider"/> can be added to an agent's context provider pipeline
|
||||
/// via <see cref="ChatClientAgentOptions.AIContextProviders"/> or via <c>UseAIContextProviders</c>
|
||||
/// on a <see cref="ChatClientBuilder"/> or <see cref="AIAgentBuilder"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class CompactionProvider : AIContextProvider
|
||||
{
|
||||
private readonly CompactionStrategy _compactionStrategy;
|
||||
private readonly ProviderSessionState<State> _sessionState;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="compactionStrategy">The compaction strategy to apply before each invocation.</param>
|
||||
/// <param name="stateKey">
|
||||
/// An optional key used to store the provider state in the <see cref="AgentSession.StateBag"/>. Provide
|
||||
/// an explicit value if configuring multiple agents with different compaction strategies that will interact
|
||||
/// in the same session.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// An optional <see cref="ILoggerFactory"/> used to create a logger for provider diagnostics.
|
||||
/// When <see langword="null"/>, logging is disabled.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="compactionStrategy"/> is <see langword="null"/>.</exception>
|
||||
public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._compactionStrategy = Throw.IfNull(compactionStrategy);
|
||||
stateKey ??= this._compactionStrategy.GetType().Name;
|
||||
this.StateKeys = [stateKey];
|
||||
this._sessionState = new ProviderSessionState<State>(
|
||||
_ => new State(),
|
||||
stateKey,
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
this._loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Applies compaction strategy to the provided message list and returns the compacted messages.
|
||||
/// This can be used for ad-hoc compaction outside of the provider pipeline.
|
||||
/// </summary>
|
||||
/// <param name="compactionStrategy">The compaction strategy to apply before each invocation.</param>
|
||||
/// <param name="messages">The messages to compact</param>
|
||||
/// <param name="logger">An optional <see cref="ILogger"/> for emitting compaction diagnostics.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>An enumeration of the compacted <see cref="ChatMessage"/> instances.</returns>
|
||||
public static async Task<IEnumerable<ChatMessage>> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable<ChatMessage> messages, ILogger? logger = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(compactionStrategy);
|
||||
Throw.IfNull(messages);
|
||||
|
||||
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages];
|
||||
CompactionMessageIndex messageIndex = CompactionMessageIndex.Create(messageList);
|
||||
|
||||
await compactionStrategy.CompactAsync(messageIndex, logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return messageIndex.GetIncludedMessages();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the compaction strategy to the accumulated message list before forwarding it to the agent.
|
||||
/// </summary>
|
||||
/// <param name="context">Contains the request context including all accumulated messages.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains an <see cref="AIContext"/>
|
||||
/// with the compacted message list.
|
||||
/// </returns>
|
||||
protected override async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.CompactionProviderInvoke);
|
||||
|
||||
ILoggerFactory loggerFactory = this.GetLoggerFactory(context.Agent);
|
||||
ILogger logger = loggerFactory.CreateLogger<CompactionProvider>();
|
||||
|
||||
AgentSession? session = context.Session;
|
||||
IEnumerable<ChatMessage>? allMessages = context.AIContext.Messages;
|
||||
|
||||
if (session is null || allMessages is null)
|
||||
{
|
||||
logger.LogCompactionProviderSkipped("no session or no messages");
|
||||
return context.AIContext;
|
||||
}
|
||||
|
||||
ChatClientAgentSession? chatClientSession = session.GetService<ChatClientAgentSession>();
|
||||
if (chatClientSession is not null &&
|
||||
!string.IsNullOrWhiteSpace(chatClientSession.ConversationId))
|
||||
{
|
||||
logger.LogCompactionProviderSkipped("session managed by remote service");
|
||||
return context.AIContext;
|
||||
}
|
||||
|
||||
List<ChatMessage> messageList = allMessages as List<ChatMessage> ?? [.. allMessages];
|
||||
|
||||
State state = this._sessionState.GetOrInitializeState(session);
|
||||
|
||||
CompactionMessageIndex messageIndex;
|
||||
if (state.MessageGroups.Count > 0)
|
||||
{
|
||||
// Update existing index with any new messages appended since the last call.
|
||||
messageIndex = new([.. state.MessageGroups]);
|
||||
messageIndex.Update(messageList);
|
||||
}
|
||||
else
|
||||
{
|
||||
// First pass — initialize the message index from scratch.
|
||||
messageIndex = CompactionMessageIndex.Create(messageList);
|
||||
}
|
||||
|
||||
string strategyName = this._compactionStrategy.GetType().Name;
|
||||
int beforeMessages = messageIndex.IncludedMessageCount;
|
||||
logger.LogCompactionProviderApplying(beforeMessages, strategyName);
|
||||
|
||||
// Apply compaction
|
||||
await this._compactionStrategy.CompactAsync(
|
||||
messageIndex,
|
||||
loggerFactory.CreateLogger(this._compactionStrategy.GetType()),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int afterMessages = messageIndex.IncludedMessageCount;
|
||||
if (afterMessages < beforeMessages)
|
||||
{
|
||||
logger.LogCompactionProviderApplied(beforeMessages, afterMessages);
|
||||
}
|
||||
|
||||
// Persist the index
|
||||
state.MessageGroups.Clear();
|
||||
state.MessageGroups.AddRange(messageIndex.Groups);
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Instructions = context.AIContext.Instructions,
|
||||
Messages = messageIndex.GetIncludedMessages(),
|
||||
Tools = context.AIContext.Tools
|
||||
};
|
||||
}
|
||||
|
||||
private ILoggerFactory GetLoggerFactory(AIAgent agent) =>
|
||||
this._loggerFactory ??
|
||||
agent.GetService<IChatClient>()?.GetService<ILoggerFactory>() ??
|
||||
NullLoggerFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the persisted state of a <see cref="CompactionProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
internal sealed class State
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the message index groups used for incremental compaction updates.
|
||||
/// </summary>
|
||||
[JsonPropertyName("messagegroups")]
|
||||
public List<CompactionMessageGroup> MessageGroups { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for strategies that compact a <see cref="CompactionMessageIndex"/> to reduce context size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Compaction strategies operate on <see cref="CompactionMessageIndex"/> instances, which organize messages
|
||||
/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
|
||||
/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every strategy requires a <see cref="CompactionTrigger"/> that determines whether compaction should
|
||||
/// proceed based on current <see cref="CompactionMessageIndex"/> metrics (token count, message count, turn count, etc.).
|
||||
/// The base class evaluates this trigger at the start of <see cref="CompactAsync"/> and skips compaction when
|
||||
/// the trigger returns <see langword="false"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An optional <b>target</b> condition controls when compaction stops. Strategies incrementally exclude
|
||||
/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
|
||||
/// <see langword="true"/>. When no target is specified, it defaults to the inverse of the trigger —
|
||||
/// meaning compaction stops when the trigger condition would no longer fire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Strategies can be applied at three lifecycle points:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
|
||||
/// <item><description><b>Pre-write</b>: Before persisting messages to storage via <see cref="ChatHistoryProvider"/>.</description></item>
|
||||
/// <item><description><b>On existing storage</b>: As a maintenance operation to compact stored history.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="CompactionMessageIndex"/>
|
||||
/// via <see cref="PipelineCompactionStrategy"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public abstract class CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that determines whether compaction should proceed.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. Strategies re-evaluate
|
||||
/// this predicate after each incremental exclusion and stop when it returns <see langword="true"/>.
|
||||
/// When <see langword="null"/>, defaults to the inverse of the <paramref name="trigger"/> — compaction
|
||||
/// stops as soon as the trigger condition would no longer fire.
|
||||
/// </param>
|
||||
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
|
||||
{
|
||||
this.Trigger = Throw.IfNull(trigger);
|
||||
this.Target = target ?? (index => !trigger(index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the trigger predicate that controls when compaction proceeds.
|
||||
/// </summary>
|
||||
protected CompactionTrigger Trigger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the target predicate that controls when compaction stops.
|
||||
/// Strategies re-evaluate this after each incremental exclusion and stop when it returns <see langword="true"/>.
|
||||
/// </summary>
|
||||
protected CompactionTrigger Target { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Applies the strategy-specific compaction logic to the specified message index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is called by <see cref="CompactAsync"/> only when the <see cref="Trigger"/>
|
||||
/// returns <see langword="true"/>. Implementations do not need to evaluate the trigger or
|
||||
/// report metrics — the base class handles both. Implementations should use <see cref="Target"/>
|
||||
/// to determine when to stop compacting incrementally.
|
||||
/// </remarks>
|
||||
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> for emitting compaction diagnostics.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task whose result is <see langword="true"/> if any compaction was performed, <see langword="false"/> otherwise.</returns>
|
||||
protected abstract ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the <see cref="Trigger"/> and, when it fires, delegates to
|
||||
/// <see cref="CompactCoreAsync"/> and reports compaction metrics.
|
||||
/// </summary>
|
||||
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
|
||||
/// <param name="logger">An optional <see cref="ILogger"/> for emitting compaction diagnostics. When <see langword="null"/>, logging is disabled.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred, <see langword="false"/> otherwise.</returns>
|
||||
public async ValueTask<bool> CompactAsync(CompactionMessageIndex index, ILogger? logger = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string strategyName = this.GetType().Name;
|
||||
logger ??= NullLogger.Instance;
|
||||
|
||||
using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Compact);
|
||||
activity?.SetTag(CompactionTelemetry.Tags.Strategy, strategyName);
|
||||
|
||||
if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index))
|
||||
{
|
||||
activity?.SetTag(CompactionTelemetry.Tags.Triggered, false);
|
||||
logger.LogCompactionSkipped(strategyName);
|
||||
return false;
|
||||
}
|
||||
|
||||
activity?.SetTag(CompactionTelemetry.Tags.Triggered, true);
|
||||
|
||||
int beforeTokens = index.IncludedTokenCount;
|
||||
int beforeGroups = index.IncludedGroupCount;
|
||||
int beforeMessages = index.IncludedMessageCount;
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
bool compacted = await this.CompactCoreAsync(index, logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
activity?.SetTag(CompactionTelemetry.Tags.Compacted, compacted);
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
activity?
|
||||
.SetTag(CompactionTelemetry.Tags.BeforeTokens, beforeTokens)
|
||||
.SetTag(CompactionTelemetry.Tags.AfterTokens, index.IncludedTokenCount)
|
||||
.SetTag(CompactionTelemetry.Tags.BeforeMessages, beforeMessages)
|
||||
.SetTag(CompactionTelemetry.Tags.AfterMessages, index.IncludedMessageCount)
|
||||
.SetTag(CompactionTelemetry.Tags.BeforeGroups, beforeGroups)
|
||||
.SetTag(CompactionTelemetry.Tags.AfterGroups, index.IncludedGroupCount)
|
||||
.SetTag(CompactionTelemetry.Tags.DurationMs, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
logger.LogCompactionCompleted(
|
||||
strategyName,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
beforeMessages,
|
||||
index.IncludedMessageCount,
|
||||
beforeGroups,
|
||||
index.IncludedGroupCount,
|
||||
beforeTokens,
|
||||
index.IncludedTokenCount);
|
||||
}
|
||||
|
||||
return compacted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the provided value is not a negative number.
|
||||
/// </summary>
|
||||
/// <param name="value">The target value.</param>
|
||||
/// <returns>0 if negative; otherwise the value</returns>
|
||||
protected static int EnsureNonNegative(int value) => Math.Max(0, value);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Provides shared telemetry infrastructure for compaction operations.
|
||||
/// </summary>
|
||||
internal static class CompactionTelemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="ActivitySource"/> used to create activities for compaction operations.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new(OpenTelemetryConsts.DefaultSourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Activity names used by compaction tracing.
|
||||
/// </summary>
|
||||
public static class ActivityNames
|
||||
{
|
||||
public const string Compact = "compaction.compact";
|
||||
public const string CompactionProviderInvoke = "compaction.provider.invoke";
|
||||
public const string Summarize = "compaction.summarize";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tag names used on compaction activities.
|
||||
/// </summary>
|
||||
public static class Tags
|
||||
{
|
||||
public const string Strategy = "compaction.strategy";
|
||||
public const string Triggered = "compaction.triggered";
|
||||
public const string Compacted = "compaction.compacted";
|
||||
public const string BeforeTokens = "compaction.before.tokens";
|
||||
public const string AfterTokens = "compaction.after.tokens";
|
||||
public const string BeforeMessages = "compaction.before.messages";
|
||||
public const string AfterMessages = "compaction.after.messages";
|
||||
public const string BeforeGroups = "compaction.before.groups";
|
||||
public const string AfterGroups = "compaction.after.groups";
|
||||
public const string DurationMs = "compaction.duration_ms";
|
||||
public const string GroupsSummarized = "compaction.groups_summarized";
|
||||
public const string SummaryLength = "compaction.summary_length";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a condition based on <see cref="CompactionMessageIndex"/> metrics used by a <see cref="CompactionStrategy"/>
|
||||
/// to determine when to trigger compaction and when the target compaction threshold has been met.
|
||||
/// </summary>
|
||||
/// <param name="index">An index over conversation messages that provides group, token, message, and turn metrics.</param>
|
||||
/// <returns><see langword="true"/> to indicate the condition has been met; otherwise <see langword="false"/>.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public delegate bool CompactionTrigger(CompactionMessageIndex index);
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Factory to create <see cref="CompactionTrigger"/> predicates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A <see cref="CompactionTrigger"/> defines a condition based on <see cref="CompactionMessageIndex"/> metrics used
|
||||
/// by a <see cref="CompactionStrategy"/> to determine when to trigger compaction and when the target
|
||||
/// compaction threshold has been met.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Combine triggers with <see cref="All"/> or <see cref="Any"/> for compound conditions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static class CompactionTriggers
|
||||
{
|
||||
/// <summary>
|
||||
/// Always trigger, regardless of the message index state.
|
||||
/// </summary>
|
||||
public static readonly CompactionTrigger Always =
|
||||
_ => true;
|
||||
|
||||
/// <summary>
|
||||
/// Never trigger, regardless of the message index state.
|
||||
/// </summary>
|
||||
public static readonly CompactionTrigger Never =
|
||||
_ => false;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included token count is below the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The token threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
|
||||
public static CompactionTrigger TokensBelow(int maxTokens) =>
|
||||
index => index.IncludedTokenCount < maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The token threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
|
||||
public static CompactionTrigger TokensExceed(int maxTokens) =>
|
||||
index => index.IncludedTokenCount > maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included message count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxMessages">The message threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included message count.</returns>
|
||||
public static CompactionTrigger MessagesExceed(int maxMessages) =>
|
||||
index => index.IncludedMessageCount > maxMessages;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTurns">The turn threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included turn count.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A user turn starts with a <see cref="CompactionGroupKind.User"/> group and includes all subsequent
|
||||
/// non-user, non-system groups until the next user group or end of conversation. Each group is assigned
|
||||
/// a <see cref="CompactionMessageGroup.TurnIndex"/> indicating which user turn it belongs to.
|
||||
/// System messages (<see cref="CompactionGroupKind.System"/>) are always assigned a <see langword="null"/>
|
||||
/// <see cref="CompactionMessageGroup.TurnIndex"/> since they never belong to a user turn.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The turn count is the number of distinct values defined by <see cref="CompactionMessageGroup.TurnIndex"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static CompactionTrigger TurnsExceed(int maxTurns) =>
|
||||
index => index.IncludedTurnCount > maxTurns;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included group count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxGroups">The group threshold.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included group count.</returns>
|
||||
public static CompactionTrigger GroupsExceed(int maxGroups) =>
|
||||
index => index.IncludedGroupCount > maxGroups;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included message index contains at least one
|
||||
/// non-excluded <see cref="CompactionGroupKind.ToolCall"/> group.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included tool call presence.</returns>
|
||||
public static CompactionTrigger HasToolCalls() =>
|
||||
index => index.Groups.Any(g => !g.IsExcluded && g.Kind == CompactionGroupKind.ToolCall);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compound trigger that fires only when <b>all</b> of the specified triggers fire.
|
||||
/// </summary>
|
||||
/// <param name="triggers">The triggers to combine with logical AND.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that requires all conditions to be met.</returns>
|
||||
public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
|
||||
index =>
|
||||
{
|
||||
for (int i = 0; i < triggers.Length; i++)
|
||||
{
|
||||
if (!triggers[i](index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compound trigger that fires when <b>any</b> of the specified triggers fire.
|
||||
/// </summary>
|
||||
/// <param name="triggers">The triggers to combine with logical OR.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that requires at least one condition to be met.</returns>
|
||||
public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
|
||||
index =>
|
||||
{
|
||||
for (int i = 0; i < triggers.Length; i++)
|
||||
{
|
||||
if (triggers[i](index))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that executes a sequential pipeline of <see cref="CompactionStrategy"/> instances
|
||||
/// against the same <see cref="CompactionMessageIndex"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
|
||||
/// such as summarizing older messages first and then truncating to fit a token budget.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pipeline itself always executes while each child strategy evaluates its own
|
||||
/// <see cref="CompactionStrategy.Trigger"/> independently to decide whether it should compact.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class PipelineCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="strategies">The ordered sequence of strategies to execute.</param>
|
||||
public PipelineCompactionStrategy(params IEnumerable<CompactionStrategy> strategies)
|
||||
: base(CompactionTriggers.Always)
|
||||
{
|
||||
this.Strategies = [.. Throw.IfNull(strategies)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered list of strategies in this pipeline.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CompactionStrategy> Strategies { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
bool anyCompacted = false;
|
||||
|
||||
foreach (CompactionStrategy strategy in this.Strategies)
|
||||
{
|
||||
bool compacted = await strategy.CompactAsync(index, logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
anyCompacted = true;
|
||||
}
|
||||
}
|
||||
|
||||
return anyCompacted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that removes the oldest user turns and their associated response groups
|
||||
/// to bound conversation length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy always preserves system messages. It identifies user turns in the
|
||||
/// conversation (via <see cref="CompactionMessageGroup.TurnIndex"/>) and excludes the oldest turns
|
||||
/// one at a time until the <see cref="CompactionStrategy.Target"/> condition is met.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreservedTurns"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedTurns"/> turns
|
||||
/// (by <see cref="CompactionMessageGroup.TurnIndex"/>). Groups with a <see cref="CompactionMessageGroup.TurnIndex"/>
|
||||
/// of <c>0</c> or <see langword="null"/> are always preserved regardless of this setting.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This strategy is more predictable than token-based truncation for bounding conversation
|
||||
/// length, since it operates on logical turn boundaries rather than estimated token counts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent turns to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// Use <see cref="CompactionTriggers.TurnsExceed"/> for turn-based thresholds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreservedTurns">
|
||||
/// The minimum number of most-recent turns (by <see cref="CompactionMessageGroup.TurnIndex"/>) to preserve.
|
||||
/// This is a hard floor — compaction will not exclude turns within this range, regardless of the target condition.
|
||||
/// Groups with <see cref="CompactionMessageGroup.TurnIndex"/> of <c>0</c> or <see langword="null"/> are always preserved.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreservedTurns = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreservedTurns = EnsureNonNegative(minimumPreservedTurns);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent turns (by <see cref="CompactionMessageGroup.TurnIndex"/>) that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// Groups with <see cref="CompactionMessageGroup.TurnIndex"/> of <c>0</c> or <see langword="null"/> are always preserved
|
||||
/// independently of this value.
|
||||
/// </summary>
|
||||
public int MinimumPreservedTurns { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// Forward pass: pre-index non-system included groups by TurnIndex.
|
||||
Dictionary<int, List<int>> turnGroups = [];
|
||||
List<int> turnOrder = [];
|
||||
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
CompactionMessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System && group.TurnIndex is int turnIndex)
|
||||
{
|
||||
if (!turnGroups.TryGetValue(turnIndex, out List<int>? indices))
|
||||
{
|
||||
indices = [];
|
||||
turnGroups[turnIndex] = indices;
|
||||
turnOrder.Add(turnIndex);
|
||||
}
|
||||
|
||||
indices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Backward pass: identify protected turns by TurnIndex.
|
||||
// TurnIndex = 0 is always protected (non-system messages before first user message).
|
||||
// TurnIndex = null is always protected (system messages, already excluded from turn tracking).
|
||||
HashSet<int> protectedTurnIndices = [];
|
||||
if (turnGroups.ContainsKey(0))
|
||||
{
|
||||
protectedTurnIndices.Add(0);
|
||||
}
|
||||
|
||||
// Protect the last MinimumPreservedTurns distinct turns.
|
||||
int turnsToProtect = Math.Min(this.MinimumPreservedTurns, turnOrder.Count);
|
||||
for (int i = turnOrder.Count - turnsToProtect; i < turnOrder.Count; i++)
|
||||
{
|
||||
protectedTurnIndices.Add(turnOrder[i]);
|
||||
}
|
||||
|
||||
// Exclude turns oldest-first, skipping protected turns, checking target after each turn.
|
||||
bool compacted = false;
|
||||
|
||||
for (int t = 0; t < turnOrder.Count; t++)
|
||||
{
|
||||
int currentTurnIndex = turnOrder[t];
|
||||
if (protectedTurnIndices.Contains(currentTurnIndex))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
List<int> groupIndices = turnGroups[currentTurnIndex];
|
||||
for (int g = 0; g < groupIndices.Count; g++)
|
||||
{
|
||||
int idx = groupIndices[g];
|
||||
index.Groups[idx].IsExcluded = true;
|
||||
index.Groups[idx].ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
|
||||
}
|
||||
|
||||
compacted = true;
|
||||
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new ValueTask<bool>(compacted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that uses an LLM to summarize older portions of the conversation,
|
||||
/// replacing them with a single summary message that preserves key facts and context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy protects system messages and the most recent <see cref="MinimumPreservedGroups"/>
|
||||
/// non-system groups. All older groups are collected and sent to the <see cref="IChatClient"/>
|
||||
/// for summarization. The resulting summary replaces those messages as a single assistant message
|
||||
/// with <see cref="CompactionGroupKind.Summary"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds. Use
|
||||
/// <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class SummarizationCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default summarization prompt used when none is provided.
|
||||
/// </summary>
|
||||
public const string DefaultSummarizationPrompt =
|
||||
"""
|
||||
You are a conversation summarizer. Produce a concise summary of the conversation that preserves:
|
||||
|
||||
- Key facts, decisions, and user preferences
|
||||
- Important context needed for future turns
|
||||
- Tool call outcomes and their significance
|
||||
|
||||
Omit pleasantries and redundant exchanges. Be factual and brief.
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent non-system groups to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The <see cref="IChatClient"/> to use for generating summaries. A smaller, faster model is recommended.</param>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreservedGroups">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not summarize groups beyond this limit,
|
||||
/// regardless of the target condition. Defaults to 8, preserving the current and recent exchanges.
|
||||
/// </param>
|
||||
/// <param name="summarizationPrompt">
|
||||
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
|
||||
/// <see cref="DefaultSummarizationPrompt"/> is used.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public SummarizationCompactionStrategy(
|
||||
IChatClient chatClient,
|
||||
CompactionTrigger trigger,
|
||||
int minimumPreservedGroups = DefaultMinimumPreserved,
|
||||
string? summarizationPrompt = null,
|
||||
CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.ChatClient = Throw.IfNull(chatClient);
|
||||
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
|
||||
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat client used for generating summaries.
|
||||
/// </summary>
|
||||
public IChatClient ChatClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreservedGroups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prompt used when requesting summaries from the chat client.
|
||||
/// </summary>
|
||||
public string SummarizationPrompt { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// Count non-system, non-excluded groups to determine which are protected
|
||||
int nonSystemIncludedCount = 0;
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
CompactionMessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
|
||||
{
|
||||
nonSystemIncludedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
int protectedFromEnd = Math.Min(this.MinimumPreservedGroups, nonSystemIncludedCount);
|
||||
int maxSummarizable = nonSystemIncludedCount - protectedFromEnd;
|
||||
|
||||
if (maxSummarizable <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark oldest non-system groups for summarization one at a time until the target is met.
|
||||
// Track which groups were excluded so we can restore them if the LLM call fails.
|
||||
List<ChatMessage> summarizationMessages = [new ChatMessage(ChatRole.System, this.SummarizationPrompt)];
|
||||
List<CompactionMessageGroup> excludedGroups = [];
|
||||
int insertIndex = -1;
|
||||
|
||||
for (int i = 0; i < index.Groups.Count && excludedGroups.Count < maxSummarizable; i++)
|
||||
{
|
||||
CompactionMessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == CompactionGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (insertIndex < 0)
|
||||
{
|
||||
insertIndex = i;
|
||||
}
|
||||
|
||||
// Collect messages from this group for summarization
|
||||
summarizationMessages.AddRange(group.Messages);
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
|
||||
excludedGroups.Add(group);
|
||||
|
||||
// Stop marking when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summary using the chat client (single LLM call for all marked groups)
|
||||
int summarized = excludedGroups.Count;
|
||||
logger.LogSummarizationStarting(summarized, summarizationMessages.Count - 1, this.ChatClient.GetType().Name);
|
||||
|
||||
using Activity? summarizeActivity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Summarize);
|
||||
summarizeActivity?.SetTag(CompactionTelemetry.Tags.GroupsSummarized, summarized);
|
||||
|
||||
ChatResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.ChatClient.GetResponseAsync(
|
||||
summarizationMessages,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Restore excluded groups so the conversation is not left in an inconsistent state
|
||||
for (int i = 0; i < excludedGroups.Count; i++)
|
||||
{
|
||||
excludedGroups[i].IsExcluded = false;
|
||||
excludedGroups[i].ExcludeReason = null;
|
||||
}
|
||||
|
||||
logger.LogSummarizationFailed(summarized, ex.Message);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
|
||||
|
||||
summarizeActivity?.SetTag(CompactionTelemetry.Tags.SummaryLength, summaryText.Length);
|
||||
|
||||
// Insert a summary group at the position of the first summarized group
|
||||
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}");
|
||||
(summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
|
||||
|
||||
index.InsertGroup(insertIndex, CompactionGroupKind.Summary, [summaryMessage]);
|
||||
|
||||
logger.LogSummarizationCompleted(summaryText.Length, insertIndex);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that collapses old tool call groups into single concise assistant
|
||||
/// messages, removing the detailed tool results while preserving a record of which tools were called
|
||||
/// and what they returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the gentlest compaction strategy — it does not remove any user messages or
|
||||
/// plain assistant responses. It only targets <see cref="CompactionGroupKind.ToolCall"/>
|
||||
/// groups outside the protected recent window, replacing each multi-message group
|
||||
/// (assistant call + tool results) with a single assistant message in a YAML-like format:
|
||||
/// <code>
|
||||
/// [Tool Calls]
|
||||
/// get_weather:
|
||||
/// - Sunny and 72°F
|
||||
/// search_docs:
|
||||
/// - Found 3 docs
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds. Use
|
||||
/// <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent non-system groups to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreservedGroups">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not collapse groups beyond this limit,
|
||||
/// regardless of the target condition.
|
||||
/// Defaults to <see cref="DefaultMinimumPreserved"/>, ensuring the current turn's tool interactions remain visible.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreservedGroups { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// Identify protected groups: the N most-recent non-system, non-excluded groups
|
||||
List<int> nonSystemIncludedIndices = [];
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
CompactionMessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
|
||||
{
|
||||
nonSystemIncludedIndices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
int protectedStart = EnsureNonNegative(nonSystemIncludedIndices.Count - this.MinimumPreservedGroups);
|
||||
HashSet<int> protectedGroupIndices = [];
|
||||
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
|
||||
{
|
||||
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
|
||||
}
|
||||
|
||||
// Collect eligible tool groups in order (oldest first)
|
||||
List<int> eligibleIndices = [];
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
CompactionMessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
|
||||
{
|
||||
eligibleIndices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (eligibleIndices.Count == 0)
|
||||
{
|
||||
return new ValueTask<bool>(false);
|
||||
}
|
||||
|
||||
// Collapse one tool group at a time from oldest, re-checking target after each
|
||||
bool compacted = false;
|
||||
int offset = 0;
|
||||
|
||||
for (int e = 0; e < eligibleIndices.Count; e++)
|
||||
{
|
||||
int idx = eligibleIndices[e] + offset;
|
||||
CompactionMessageGroup group = index.Groups[idx];
|
||||
|
||||
string summary = BuildToolCallSummary(group);
|
||||
|
||||
// Exclude the original group and insert a collapsed replacement
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
|
||||
|
||||
ChatMessage summaryMessage = new(ChatRole.Assistant, summary);
|
||||
(summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
|
||||
|
||||
index.InsertGroup(idx + 1, CompactionGroupKind.Summary, [summaryMessage], group.TurnIndex);
|
||||
offset++; // Each insertion shifts subsequent indices by 1
|
||||
|
||||
compacted = true;
|
||||
|
||||
// Stop when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new ValueTask<bool>(compacted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a concise summary string for a tool call group, including tool names,
|
||||
/// results, and deduplication counts for repeated tool names.
|
||||
/// </summary>
|
||||
private static string BuildToolCallSummary(CompactionMessageGroup group)
|
||||
{
|
||||
// Collect function calls (callId, name) and results (callId → result text)
|
||||
List<(string CallId, string Name)> functionCalls = [];
|
||||
Dictionary<string, string> resultsByCallId = new();
|
||||
List<string> plainTextResults = [];
|
||||
|
||||
foreach (ChatMessage message in group.Messages)
|
||||
{
|
||||
if (message.Contents is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasFunctionResult = false;
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent fcc)
|
||||
{
|
||||
functionCalls.Add((fcc.CallId, fcc.Name));
|
||||
}
|
||||
else if (content is FunctionResultContent frc && frc.CallId is not null)
|
||||
{
|
||||
resultsByCallId[frc.CallId] = frc.Result?.ToString() ?? string.Empty;
|
||||
hasFunctionResult = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect plain text from Tool-role messages that lack FunctionResultContent
|
||||
if (!hasFunctionResult && message.Role == ChatRole.Tool && message.Text is string text)
|
||||
{
|
||||
plainTextResults.Add(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Match function calls to their results using CallId or positional fallback,
|
||||
// grouping by tool name while preserving first-seen order.
|
||||
int plainTextIdx = 0;
|
||||
List<string> orderedNames = [];
|
||||
Dictionary<string, List<string>> groupedResults = new();
|
||||
|
||||
foreach ((string callId, string name) in functionCalls)
|
||||
{
|
||||
if (!groupedResults.TryGetValue(name, out _))
|
||||
{
|
||||
orderedNames.Add(name);
|
||||
groupedResults[name] = [];
|
||||
}
|
||||
|
||||
string? result = null;
|
||||
if (resultsByCallId.TryGetValue(callId, out string? matchedResult))
|
||||
{
|
||||
result = matchedResult;
|
||||
}
|
||||
else if (plainTextIdx < plainTextResults.Count)
|
||||
{
|
||||
result = plainTextResults[plainTextIdx++];
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(result))
|
||||
{
|
||||
groupedResults[name].Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Format as YAML-like block with [Tool Calls] header
|
||||
List<string> lines = ["[Tool Calls]"];
|
||||
foreach (string name in orderedNames)
|
||||
{
|
||||
List<string> results = groupedResults[name];
|
||||
|
||||
lines.Add($"{name}:");
|
||||
if (results.Count > 0)
|
||||
{
|
||||
foreach (string result in results)
|
||||
{
|
||||
lines.Add($" - {result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that removes the oldest non-system message groups,
|
||||
/// keeping at least <see cref="MinimumPreservedGroups"/> most-recent groups intact.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy preserves system messages and removes the oldest non-system message groups first.
|
||||
/// It respects atomic group boundaries — an assistant message with tool calls and its
|
||||
/// corresponding tool result messages are always removed together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> controls when compaction proceeds.
|
||||
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or group thresholds.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class TruncationCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent non-system groups to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreservedGroups">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not remove groups beyond this limit,
|
||||
/// regardless of the target condition.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system message groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreservedGroups { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// Count removable (non-system, non-excluded) groups
|
||||
int removableCount = 0;
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
CompactionMessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
|
||||
{
|
||||
removableCount++;
|
||||
}
|
||||
}
|
||||
|
||||
int maxRemovable = removableCount - this.MinimumPreservedGroups;
|
||||
if (maxRemovable <= 0)
|
||||
{
|
||||
return new ValueTask<bool>(false);
|
||||
}
|
||||
|
||||
// Exclude oldest non-system groups one at a time, re-checking target after each
|
||||
bool compacted = false;
|
||||
int removed = 0;
|
||||
for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
|
||||
{
|
||||
CompactionMessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == CompactionGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
|
||||
removed++;
|
||||
compacted = true;
|
||||
|
||||
// Stop when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new ValueTask<bool>(compacted);
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -36,7 +40,7 @@
|
||||
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Declarative.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests"/>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="ChatMessageContentEquality"/> extension methods.
|
||||
/// </summary>
|
||||
public class ChatMessageContentEqualityTests
|
||||
{
|
||||
#region Null and reference handling
|
||||
|
||||
[Fact]
|
||||
public void BothNullReturnsTrue()
|
||||
{
|
||||
ChatMessage? a = null;
|
||||
ChatMessage? b = null;
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeftNullReturnsFalse()
|
||||
{
|
||||
ChatMessage? a = null;
|
||||
ChatMessage b = new(ChatRole.User, "Hello");
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RightNullReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello");
|
||||
ChatMessage? b = null;
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameReferenceReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello");
|
||||
|
||||
Assert.True(a.ContentEquals(a));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MessageId shortcut
|
||||
|
||||
[Fact]
|
||||
public void MatchingMessageIdReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
|
||||
ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchingMessageIdSufficientDespiteDifferentContent()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
|
||||
ChatMessage b = new(ChatRole.Assistant, "Goodbye") { MessageId = "msg-1" };
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentMessageIdReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
|
||||
ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-2" };
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyLeftHasMessageIdFallsThroughToContentComparison()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
|
||||
ChatMessage b = new(ChatRole.User, "Hello");
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyRightHasMessageIdFallsThroughToContentComparison()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello");
|
||||
ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Role and AuthorName
|
||||
|
||||
[Fact]
|
||||
public void DifferentRoleReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello");
|
||||
ChatMessage b = new(ChatRole.Assistant, "Hello");
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentAuthorNameReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello") { AuthorName = "Alice" };
|
||||
ChatMessage b = new(ChatRole.User, "Hello") { AuthorName = "Bob" };
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothNullAuthorNamesAreEqual()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello");
|
||||
ChatMessage b = new(ChatRole.User, "Hello");
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TextContent
|
||||
|
||||
[Fact]
|
||||
public void EqualTextContentReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello world");
|
||||
ChatMessage b = new(ChatRole.User, "Hello world");
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentTextContentReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello");
|
||||
ChatMessage b = new(ChatRole.User, "Goodbye");
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextContentIsCaseSensitive()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, "Hello");
|
||||
ChatMessage b = new(ChatRole.User, "hello");
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TextReasoningContent
|
||||
|
||||
[Fact]
|
||||
public void EqualTextReasoningContentReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentReasoningTextReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("alpha")]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("beta")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentProtectedDataReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "x" }]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "y" }]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DataContent
|
||||
|
||||
[Fact]
|
||||
public void EqualDataContentReturnsTrue()
|
||||
{
|
||||
byte[] data = Encoding.UTF8.GetBytes("payload");
|
||||
ChatMessage a = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]);
|
||||
ChatMessage b = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentDataBytesReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("aaa"), "text/plain")]);
|
||||
ChatMessage b = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("bbb"), "text/plain")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentMediaTypeReturnsFalse()
|
||||
{
|
||||
byte[] data = [1, 2, 3];
|
||||
ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png")]);
|
||||
ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/jpeg")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentDataContentNameReturnsFalse()
|
||||
{
|
||||
byte[] data = [1, 2, 3];
|
||||
ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "a.png" }]);
|
||||
ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "b.png" }]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UriContent
|
||||
|
||||
[Fact]
|
||||
public void EqualUriContentReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]);
|
||||
ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentUriReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://a.com/x"), "image/png")]);
|
||||
ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://b.com/x"), "image/png")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentUriMediaTypeReturnsFalse()
|
||||
{
|
||||
Uri uri = new("https://example.com/file");
|
||||
ChatMessage a = new(ChatRole.User, [new UriContent(uri, "image/png")]);
|
||||
ChatMessage b = new(ChatRole.User, [new UriContent(uri, "image/jpeg")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ErrorContent
|
||||
|
||||
[Fact]
|
||||
public void EqualErrorContentReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentErrorMessageReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail")]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("crash")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentErrorCodeReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E002" }]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FunctionCallContent
|
||||
|
||||
[Fact]
|
||||
public void EqualFunctionCallContentReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary<string, object?> { ["city"] = "Seattle" } }]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary<string, object?> { ["city"] = "Seattle" } }]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentCallIdReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-2", "get_weather")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentFunctionNameReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_time")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentArgumentsReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "2" } }]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullArgumentsBothSidesReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneNullArgumentsReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentArgumentCountReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
|
||||
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1", ["y"] = "2" } }]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FunctionResultContent
|
||||
|
||||
[Fact]
|
||||
public void EqualFunctionResultContentReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
|
||||
ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentResultCallIdReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
|
||||
ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-2", "sunny")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentResultValueReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
|
||||
ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "rainy")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HostedFileContent
|
||||
|
||||
[Fact]
|
||||
public void EqualHostedFileContentReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]);
|
||||
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentFileIdReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc")]);
|
||||
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-xyz")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentHostedFileMediaTypeReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv" }]);
|
||||
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/plain" }]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentHostedFileNameReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "a.csv" }]);
|
||||
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "b.csv" }]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Content list structure
|
||||
|
||||
[Fact]
|
||||
public void DifferentContentCountReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, [new TextContent("one"), new TextContent("two")]);
|
||||
ChatMessage b = new(ChatRole.User, [new TextContent("one")]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedContentTypesInSameOrderReturnsTrue()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
|
||||
ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MismatchedContentTypeOrderReturnsFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
|
||||
ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new FunctionCallContent("c1", "fn"), new TextContent("reply") });
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyContentsListsAreEqual()
|
||||
{
|
||||
ChatMessage a = new() { Role = ChatRole.User, Contents = [] };
|
||||
ChatMessage b = new() { Role = ChatRole.User, Contents = [] };
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameContentItemReferenceReturnsTrue()
|
||||
{
|
||||
// Exercises the ReferenceEquals fast-path on individual AIContent items.
|
||||
TextContent shared = new("Hello");
|
||||
ChatMessage a = new(ChatRole.User, [shared]);
|
||||
ChatMessage b = new(ChatRole.User, [shared]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unknown AIContent subtype
|
||||
|
||||
[Fact]
|
||||
public void UnknownContentSubtypeSameTypeReturnsTrue()
|
||||
{
|
||||
// Unknown subtypes with the same concrete type are considered equal.
|
||||
ChatMessage a = new(ChatRole.User, [new StubContent()]);
|
||||
ChatMessage b = new(ChatRole.User, [new StubContent()]);
|
||||
|
||||
Assert.True(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentUnknownContentSubtypesReturnFalse()
|
||||
{
|
||||
ChatMessage a = new(ChatRole.User, [new StubContent()]);
|
||||
ChatMessage b = new(ChatRole.User, [new OtherStubContent()]);
|
||||
|
||||
Assert.False(a.ContentEquals(b));
|
||||
}
|
||||
|
||||
private sealed class StubContent : AIContent;
|
||||
|
||||
private sealed class OtherStubContent : AIContent;
|
||||
|
||||
#endregion
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="ChatReducerCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class ChatReducerCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructorNullReducerThrows()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ChatReducerCompactionStrategy(null!, CompactionTriggers.Always));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger never fires
|
||||
TestChatReducer reducer = new(messages => messages.Take(1));
|
||||
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Never);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, reducer.CallCount);
|
||||
Assert.Equal(2, index.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReducerReturnsFewerMessagesRebuildsIndexAsync()
|
||||
{
|
||||
// Arrange — reducer keeps only the last message
|
||||
TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1));
|
||||
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, reducer.CallCount);
|
||||
Assert.Equal(1, index.IncludedGroupCount);
|
||||
Assert.Equal("Second", index.Groups[0].Messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReducerReturnsSameCountReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — reducer returns all messages (no reduction)
|
||||
TestChatReducer reducer = new(messages => messages);
|
||||
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(1, reducer.CallCount);
|
||||
Assert.Equal(2, index.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncEmptyIndexReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — no included messages
|
||||
TestChatReducer reducer = new(messages => messages);
|
||||
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, reducer.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesWhenReducerKeepsThemAsync()
|
||||
{
|
||||
// Arrange — reducer keeps system + last user message
|
||||
TestChatReducer reducer = new(messages =>
|
||||
{
|
||||
var nonSystem = messages.Where(m => m.Role != ChatRole.System).ToList();
|
||||
return messages.Where(m => m.Role == ChatRole.System)
|
||||
.Concat(nonSystem.Skip(nonSystem.Count - 1));
|
||||
});
|
||||
|
||||
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, index.IncludedGroupCount);
|
||||
Assert.Equal(CompactionGroupKind.System, index.Groups[0].Kind);
|
||||
Assert.Equal("You are helpful.", index.Groups[0].Messages[0].Text);
|
||||
Assert.Equal(CompactionGroupKind.User, index.Groups[1].Kind);
|
||||
Assert.Equal("Second", index.Groups[1].Messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncRebuildsToolCallGroupsCorrectlyAsync()
|
||||
{
|
||||
// Arrange — reducer keeps last 3 messages (assistant tool call + tool result + user)
|
||||
TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 3));
|
||||
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
|
||||
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Old question"),
|
||||
new ChatMessage(ChatRole.Assistant, "Old answer"),
|
||||
assistantToolCall,
|
||||
toolResult,
|
||||
new ChatMessage(ChatRole.User, "New question"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// Should have 2 groups: ToolCall group (assistant + tool result) + User group
|
||||
Assert.Equal(2, index.IncludedGroupCount);
|
||||
Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[0].Kind);
|
||||
Assert.Equal(2, index.Groups[0].Messages.Count);
|
||||
Assert.Equal(CompactionGroupKind.User, index.Groups[1].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
|
||||
{
|
||||
// Arrange — one group is pre-excluded, reducer keeps last message
|
||||
TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1));
|
||||
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Excluded"),
|
||||
new ChatMessage(ChatRole.User, "Included 1"),
|
||||
new ChatMessage(ChatRole.User, "Included 2"),
|
||||
]);
|
||||
index.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — reducer only saw 2 included messages, kept 1
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, index.IncludedGroupCount);
|
||||
Assert.Equal("Included 2", index.Groups[0].Messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncExposesReducerPropertyAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestChatReducer reducer = new(messages => messages);
|
||||
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
|
||||
|
||||
// Assert
|
||||
Assert.Same(reducer, strategy.ChatReducer);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPassesCancellationTokenToReducerAsync()
|
||||
{
|
||||
// Arrange
|
||||
using CancellationTokenSource cancellationSource = new();
|
||||
CancellationToken capturedToken = default;
|
||||
TestChatReducer reducer = new((messages, cancellationToken) =>
|
||||
{
|
||||
capturedToken = cancellationToken;
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(messages.Skip(messages.Count() - 1).ToList());
|
||||
});
|
||||
|
||||
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index, logger: null, cancellationSource.Token);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(cancellationSource.Token, capturedToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test implementation of <see cref="IChatReducer"/> that applies a configurable reduction function.
|
||||
/// </summary>
|
||||
private sealed class TestChatReducer : IChatReducer
|
||||
{
|
||||
private readonly Func<IEnumerable<ChatMessage>, CancellationToken, Task<IEnumerable<ChatMessage>>> _reduceFunc;
|
||||
|
||||
public TestChatReducer(Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> reduceFunc)
|
||||
{
|
||||
this._reduceFunc = (messages, _) => Task.FromResult(reduceFunc(messages));
|
||||
}
|
||||
|
||||
public TestChatReducer(Func<IEnumerable<ChatMessage>, CancellationToken, Task<IEnumerable<ChatMessage>>> reduceFunc)
|
||||
{
|
||||
this._reduceFunc = reduceFunc;
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public async Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CallCount++;
|
||||
return await this._reduceFunc(messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1477
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="CompactionProvider"/> class.
|
||||
/// </summary>
|
||||
public sealed class CompactionProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructorThrowsOnNullStrategy()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new CompactionProvider(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateKeysReturnsExpectedKey()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactionProvider provider = new(strategy);
|
||||
|
||||
// Act & Assert — default state key is the strategy type name
|
||||
Assert.Single(provider.StateKeys);
|
||||
Assert.Equal(nameof(TruncationCompactionStrategy), provider.StateKeys[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateKeysAreStableAcrossEquivalentInstances()
|
||||
{
|
||||
// Arrange — two providers with equivalent (but distinct) strategies
|
||||
CompactionProvider provider1 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000)));
|
||||
CompactionProvider provider2 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000)));
|
||||
|
||||
// Act & Assert — default keys must be identical for session state stability
|
||||
Assert.Equal(provider1.StateKeys[0], provider2.StateKeys[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateKeysReturnsCustomKeyWhenProvided()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactionProvider provider = new(strategy, stateKey: "my-custom-key");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Single(provider.StateKeys);
|
||||
Assert.Equal("my-custom-key", provider.StateKeys[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsyncNoSessionPassesThroughAsync()
|
||||
{
|
||||
// Arrange — no session → passthrough
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactionProvider provider = new(strategy);
|
||||
|
||||
Mock<AIAgent> mockAgent = new() { CallBase = true };
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
];
|
||||
|
||||
AIContextProvider.InvokingContext context = new(
|
||||
mockAgent.Object,
|
||||
session: null,
|
||||
new AIContext { Messages = messages });
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
// Assert — original context returned unchanged
|
||||
Assert.Same(messages, result.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsyncNullMessagesPassesThroughAsync()
|
||||
{
|
||||
// Arrange — messages is null → passthrough
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactionProvider provider = new(strategy);
|
||||
|
||||
Mock<AIAgent> mockAgent = new() { CallBase = true };
|
||||
TestAgentSession session = new();
|
||||
AIContextProvider.InvokingContext context = new(
|
||||
mockAgent.Object,
|
||||
session,
|
||||
new AIContext { Messages = null });
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
// Assert — original context returned unchanged
|
||||
Assert.Null(result.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsyncAppliesCompactionWhenTriggeredAsync()
|
||||
{
|
||||
// Arrange — strategy that always triggers and keeps only 1 group
|
||||
TruncationCompactionStrategy strategy = new(_ => true, minimumPreservedGroups: 1);
|
||||
CompactionProvider provider = new(strategy);
|
||||
|
||||
Mock<AIAgent> mockAgent = new() { CallBase = true };
|
||||
TestAgentSession session = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
AIContextProvider.InvokingContext context = new(
|
||||
mockAgent.Object,
|
||||
session,
|
||||
new AIContext { Messages = messages });
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
// Assert — compaction should have reduced the message count
|
||||
Assert.NotNull(result.Messages);
|
||||
List<ChatMessage> resultList = [.. result.Messages!];
|
||||
Assert.True(resultList.Count < messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsyncNoCompactionNeededReturnsOriginalMessagesAsync()
|
||||
{
|
||||
// Arrange — trigger never fires → no compaction
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactionProvider provider = new(strategy);
|
||||
|
||||
Mock<AIAgent> mockAgent = new() { CallBase = true };
|
||||
TestAgentSession session = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
];
|
||||
|
||||
AIContextProvider.InvokingContext context = new(
|
||||
mockAgent.Object,
|
||||
session,
|
||||
new AIContext { Messages = messages });
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
// Assert — original messages passed through
|
||||
Assert.NotNull(result.Messages);
|
||||
List<ChatMessage> resultList = [.. result.Messages!];
|
||||
Assert.Single(resultList);
|
||||
Assert.Equal("Hello", resultList[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsyncPreservesInstructionsAndToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactionProvider provider = new(strategy);
|
||||
|
||||
Mock<AIAgent> mockAgent = new() { CallBase = true };
|
||||
TestAgentSession session = new();
|
||||
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
AITool[] tools = [AIFunctionFactory.Create(() => "tool", "MyTool")];
|
||||
|
||||
AIContextProvider.InvokingContext context = new(
|
||||
mockAgent.Object,
|
||||
session,
|
||||
new AIContext
|
||||
{
|
||||
Instructions = "Be helpful",
|
||||
Messages = messages,
|
||||
Tools = tools
|
||||
});
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
// Assert — instructions and tools are preserved
|
||||
Assert.Equal("Be helpful", result.Instructions);
|
||||
Assert.Same(tools, result.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsyncWithExistingIndexUpdatesAsync()
|
||||
{
|
||||
// Arrange — call twice to exercise the "existing index" path
|
||||
TruncationCompactionStrategy strategy = new(_ => true, minimumPreservedGroups: 1);
|
||||
CompactionProvider provider = new(strategy);
|
||||
|
||||
Mock<AIAgent> mockAgent = new() { CallBase = true };
|
||||
TestAgentSession session = new();
|
||||
|
||||
List<ChatMessage> messages1 =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
AIContextProvider.InvokingContext context1 = new(
|
||||
mockAgent.Object,
|
||||
session,
|
||||
new AIContext { Messages = messages1 });
|
||||
|
||||
// First call — initializes state
|
||||
await provider.InvokingAsync(context1);
|
||||
|
||||
List<ChatMessage> messages2 =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
];
|
||||
|
||||
AIContextProvider.InvokingContext context2 = new(
|
||||
mockAgent.Object,
|
||||
session,
|
||||
new AIContext { Messages = messages2 });
|
||||
|
||||
// Act — second call exercises the update path
|
||||
AIContext result = await provider.InvokingAsync(context2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsyncWithNonListEnumerableCreatesListCopyAsync()
|
||||
{
|
||||
// Arrange — pass IEnumerable (not List<ChatMessage>) to exercise the list copy branch
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactionProvider provider = new(strategy);
|
||||
|
||||
Mock<AIAgent> mockAgent = new() { CallBase = true };
|
||||
TestAgentSession session = new();
|
||||
|
||||
// Use an IEnumerable (not a List) to trigger the copy path
|
||||
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
|
||||
AIContextProvider.InvokingContext context = new(
|
||||
mockAgent.Object,
|
||||
session,
|
||||
new AIContext { Messages = messages });
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Messages);
|
||||
List<ChatMessage> resultList = [.. result.Messages!];
|
||||
Assert.Single(resultList);
|
||||
Assert.Equal("Hello", resultList[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncThrowsOnNullStrategyAsync()
|
||||
{
|
||||
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => CompactionProvider.CompactAsync(null!, messages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsAllMessagesWhenTriggerDoesNotFireAsync()
|
||||
{
|
||||
// Arrange — trigger never fires → no compaction
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
|
||||
|
||||
// Assert — all messages preserved
|
||||
List<ChatMessage> resultList = [.. result];
|
||||
Assert.Equal(messages.Count, resultList.Count);
|
||||
Assert.Equal("Q1", resultList[0].Text);
|
||||
Assert.Equal("A1", resultList[1].Text);
|
||||
Assert.Equal("Q2", resultList[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReducesMessagesWhenTriggeredAsync()
|
||||
{
|
||||
// Arrange — strategy that always triggers and keeps only 1 group
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
|
||||
|
||||
// Assert — compaction should have reduced the message count
|
||||
List<ChatMessage> resultList = [.. result];
|
||||
Assert.True(resultList.Count < messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncHandlesEmptyMessageListAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
|
||||
List<ChatMessage> messages = [];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWorksWithNonListEnumerableAsync()
|
||||
{
|
||||
// Arrange — IEnumerable (not a List<ChatMessage>) to exercise the list copy branch
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> resultList = [.. result];
|
||||
Assert.Single(resultList);
|
||||
Assert.Equal("Hello", resultList[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompactionStateAssignment()
|
||||
{
|
||||
// Arrange
|
||||
CompactionProvider.State state = new();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state.MessageGroups);
|
||||
Assert.Empty(state.MessageGroups);
|
||||
|
||||
// Act
|
||||
state.MessageGroups = [new CompactionMessageGroup(CompactionGroupKind.User, [], 0, 0, 0)];
|
||||
|
||||
// Assert
|
||||
Assert.Single(state.MessageGroups);
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="CompactionStrategy"/> abstract base class.
|
||||
/// </summary>
|
||||
public class CompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructorNullTriggerThrows()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new TestStrategy(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger never fires, but enough non-system groups to pass short-circuit
|
||||
TestStrategy strategy = new(_ => false);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerMetCallsApplyAsync()
|
||||
{
|
||||
// Arrange — trigger always fires, enough non-system groups
|
||||
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger fires but Apply does nothing
|
||||
TestStrategy strategy = new(_ => true, applyFunc: _ => false);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSingleNonSystemGroupShortCircuitsAsync()
|
||||
{
|
||||
// Arrange — trigger would fire, but only 1 non-system group → short-circuit
|
||||
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — short-circuited before trigger or Apply
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSingleNonSystemGroupWithSystemShortCircuitsAsync()
|
||||
{
|
||||
// Arrange — system group + 1 non-system group → still short-circuits
|
||||
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — system groups don't count, still only 1 non-system group
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTwoNonSystemGroupsProceedsToTriggerAsync()
|
||||
{
|
||||
// Arrange — exactly 2 non-system groups: boundary passes, trigger fires
|
||||
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — not short-circuited, Apply was called
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync()
|
||||
{
|
||||
// Arrange — trigger fires when groups > 2
|
||||
// Default target should be: stop when groups <= 2 (i.e., !trigger)
|
||||
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
|
||||
TestStrategy strategy = new(trigger, applyFunc: index =>
|
||||
{
|
||||
// Exclude oldest non-system group one at a time
|
||||
foreach (CompactionMessageGroup group in index.Groups)
|
||||
{
|
||||
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
|
||||
{
|
||||
group.IsExcluded = true;
|
||||
// Target (default = !trigger) returns true when groups <= 2
|
||||
// So the strategy would check Target after this exclusion
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — trigger fires (4 > 2), Apply is called
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync()
|
||||
{
|
||||
// Arrange — custom target that always signals stop
|
||||
bool targetCalled = false;
|
||||
bool CustomTarget(CompactionMessageIndex _)
|
||||
{
|
||||
targetCalled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
TestStrategy strategy = new(_ => true, CustomTarget, _ =>
|
||||
{
|
||||
// Access the target from within the strategy
|
||||
return true;
|
||||
});
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — the custom target is accessible (verified by TestStrategy checking it)
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
// The target is accessible to derived classes via the protected property
|
||||
Assert.True(strategy.InvokeTarget(index));
|
||||
Assert.True(targetCalled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A concrete test implementation of <see cref="CompactionStrategy"/> for testing the base class.
|
||||
/// </summary>
|
||||
private sealed class TestStrategy : CompactionStrategy
|
||||
{
|
||||
private readonly Func<CompactionMessageIndex, bool>? _applyFunc;
|
||||
|
||||
public TestStrategy(
|
||||
CompactionTrigger trigger,
|
||||
CompactionTrigger? target = null,
|
||||
Func<CompactionMessageIndex, bool>? applyFunc = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this._applyFunc = applyFunc;
|
||||
}
|
||||
|
||||
public int ApplyCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the protected Target property for test verification.
|
||||
/// </summary>
|
||||
public bool InvokeTarget(CompactionMessageIndex index) => this.Target(index);
|
||||
|
||||
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
this.ApplyCallCount++;
|
||||
bool result = this._applyFunc?.Invoke(index) ?? false;
|
||||
return new(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="CompactionTrigger"/> and <see cref="CompactionTriggers"/>.
|
||||
/// </summary>
|
||||
public class CompactionTriggersTests
|
||||
{
|
||||
[Fact]
|
||||
public void TokensExceedReturnsTrueWhenAboveThreshold()
|
||||
{
|
||||
// Arrange — use a long message to guarantee tokens > 0
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensExceedReturnsFalseWhenBelowThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessagesExceedReturnsExpectedResult()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
|
||||
CompactionMessageIndex small = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.User, "B"),
|
||||
]);
|
||||
CompactionMessageIndex large = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.User, "B"),
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
]);
|
||||
|
||||
Assert.False(trigger(small));
|
||||
Assert.True(trigger(large));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TurnsExceedReturnsExpectedResult()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1);
|
||||
CompactionMessageIndex oneTurn = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
]);
|
||||
CompactionMessageIndex twoTurns = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
Assert.False(trigger(oneTurn));
|
||||
Assert.True(trigger(twoTurns));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupsExceedReturnsExpectedResult()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.Assistant, "B"),
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
]);
|
||||
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasToolCallsReturnsTrueWhenToolCallGroupExists()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
]);
|
||||
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasToolCallsReturnsFalseWhenNoToolCallGroup()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllRequiresAllConditions()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.All(
|
||||
CompactionTriggers.TokensExceed(0),
|
||||
CompactionTriggers.MessagesExceed(5));
|
||||
|
||||
CompactionMessageIndex small = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
|
||||
// Tokens > 0 is true, but messages > 5 is false
|
||||
Assert.False(trigger(small));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyRequiresAtLeastOneCondition()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.Any(
|
||||
CompactionTriggers.TokensExceed(999_999),
|
||||
CompactionTriggers.MessagesExceed(0));
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
|
||||
// Tokens not exceeded, but messages > 0 is true
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEmptyTriggersReturnsTrue()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.All();
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyEmptyTriggersReturnsFalse()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.Any();
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensBelowReturnsTrueWhenBelowThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensBelowReturnsFalseWhenAboveThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensBelow(0);
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlwaysReturnsTrue()
|
||||
{
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.True(CompactionTriggers.Always(index));
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class PipelineCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncExecutesAllStrategiesInOrderAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<string> executionOrder = [];
|
||||
TestCompactionStrategy strategy1 = new(
|
||||
_ =>
|
||||
{
|
||||
executionOrder.Add("first");
|
||||
return false;
|
||||
});
|
||||
|
||||
TestCompactionStrategy strategy2 = new(
|
||||
_ =>
|
||||
{
|
||||
executionOrder.Add("second");
|
||||
return false;
|
||||
});
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(["first", "second"], executionOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestCompactionStrategy strategy1 = new(_ => false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestCompactionStrategy strategy1 = new(_ => false);
|
||||
TestCompactionStrategy strategy2 = new(_ => true);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncContinuesAfterFirstCompactionAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestCompactionStrategy strategy1 = new(_ => true);
|
||||
TestCompactionStrategy strategy2 = new(_ => false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert — both strategies were called
|
||||
Assert.Equal(1, strategy1.ApplyCallCount);
|
||||
Assert.Equal(1, strategy2.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncComposesStrategiesEndToEndAsync()
|
||||
{
|
||||
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
|
||||
static void ExcludeOldest2(CompactionMessageIndex index)
|
||||
{
|
||||
int excluded = 0;
|
||||
foreach (CompactionMessageGroup group in index.Groups)
|
||||
{
|
||||
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System && excluded < 2)
|
||||
{
|
||||
group.IsExcluded = true;
|
||||
excluded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestCompactionStrategy phase1 = new(
|
||||
index =>
|
||||
{
|
||||
ExcludeOldest2(index);
|
||||
return true;
|
||||
});
|
||||
|
||||
TestCompactionStrategy phase2 = new(
|
||||
index =>
|
||||
{
|
||||
ExcludeOldest2(index);
|
||||
return true;
|
||||
});
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(phase1, phase2);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal(2, included.Count);
|
||||
Assert.Equal("You are helpful.", included[0].Text);
|
||||
Assert.Equal("Q3", included[1].Text);
|
||||
|
||||
Assert.Equal(1, phase1.ApplyCallCount);
|
||||
Assert.Equal(1, phase2.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncEmptyPipelineReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
PipelineCompactionStrategy pipeline = new(new List<CompactionStrategy>());
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple test implementation of <see cref="CompactionStrategy"/> that delegates to a synchronous callback.
|
||||
/// </summary>
|
||||
private sealed class TestCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
private readonly Func<CompactionMessageIndex, bool> _applyFunc;
|
||||
|
||||
public TestCompactionStrategy(Func<CompactionMessageIndex, bool> applyFunc)
|
||||
: base(CompactionTriggers.Always)
|
||||
{
|
||||
this._applyFunc = applyFunc;
|
||||
}
|
||||
|
||||
public int ApplyCallCount { get; private set; }
|
||||
|
||||
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
this.ApplyCallCount++;
|
||||
return new(this._applyFunc(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class SlidingWindowCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 3 turns, conversation has 2
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3));
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync()
|
||||
{
|
||||
// Arrange — trigger on > 2 turns, conversation has 3
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(2));
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// Turn 1 (Q1 + A1) should be excluded
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
// Turn 2 and 3 should remain
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
Assert.False(groups.Groups[4].IsExcluded);
|
||||
Assert.False(groups.Groups[5].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.False(groups.Groups[0].IsExcluded); // System preserved
|
||||
Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded
|
||||
Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded
|
||||
Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
|
||||
new ChatMessage(ChatRole.Tool, "Results"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// Turn 1 excluded
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
// Turn 2 kept (user + tool call group)
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 99 turns
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99));
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal(3, included.Count);
|
||||
Assert.Equal("System", included[0].Text);
|
||||
Assert.Equal("Q2", included[1].Text);
|
||||
Assert.Equal("A2", included[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn, custom target stops after removing 1 turn
|
||||
int removeCount = 0;
|
||||
bool TargetAfterOne(CompactionMessageIndex _) => ++removeCount >= 1;
|
||||
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreservedTurns: 0,
|
||||
target: TargetAfterOne);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
new ChatMessage(ChatRole.User, "Q4"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — only turn 1 excluded (target stopped after 1 removal)
|
||||
Assert.True(result);
|
||||
Assert.True(index.Groups[0].IsExcluded); // Q1 (turn 1)
|
||||
Assert.True(index.Groups[1].IsExcluded); // A1 (turn 1)
|
||||
Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept
|
||||
Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncMinimumPreservedStopsCompactionAsync()
|
||||
{
|
||||
// Arrange — always trigger with never-satisfied target, but MinimumPreserved = 2 is hard floor
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreservedTurns: 2,
|
||||
target: _ => false);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — target never says stop, but MinimumPreserved=2 protects the last 2 turns
|
||||
Assert.True(result);
|
||||
Assert.Equal(4, index.IncludedGroupCount);
|
||||
// Turn 1 excluded
|
||||
Assert.True(index.Groups[0].IsExcluded); // Q1
|
||||
Assert.True(index.Groups[1].IsExcluded); // A1
|
||||
// Last 2 turns must be preserved
|
||||
Assert.False(index.Groups[2].IsExcluded); // Q2
|
||||
Assert.False(index.Groups[3].IsExcluded); // A2
|
||||
Assert.False(index.Groups[4].IsExcluded); // Q3
|
||||
Assert.False(index.Groups[5].IsExcluded); // A3
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync()
|
||||
{
|
||||
// Arrange — includes system and pre-excluded groups that must be skipped
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreservedTurns: 0);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
// Pre-exclude one group
|
||||
index.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — system preserved, pre-excluded skipped
|
||||
Assert.True(result);
|
||||
Assert.False(index.Groups[0].IsExcluded); // System preserved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesTurnIndexZeroAsync()
|
||||
{
|
||||
// Arrange — assistant message before first user turn gets TurnIndex = 0
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreservedTurns: 0,
|
||||
target: _ => false);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "Welcome!"), // TurnIndex = 0
|
||||
new ChatMessage(ChatRole.User, "Q1"), // TurnIndex = 1
|
||||
new ChatMessage(ChatRole.Assistant, "A1"), // TurnIndex = 1
|
||||
new ChatMessage(ChatRole.User, "Q2"), // TurnIndex = 2
|
||||
new ChatMessage(ChatRole.Assistant, "A2"), // TurnIndex = 2
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — TurnIndex = 0 is always preserved even with minimumPreservedTurns = 0
|
||||
Assert.True(result);
|
||||
Assert.False(index.Groups[0].IsExcluded); // Welcome (TurnIndex 0) preserved
|
||||
Assert.True(index.Groups[1].IsExcluded); // Q1 (TurnIndex 1) excluded
|
||||
Assert.True(index.Groups[2].IsExcluded); // A1 (TurnIndex 1) excluded
|
||||
Assert.True(index.Groups[3].IsExcluded); // Q2 (TurnIndex 2) excluded
|
||||
Assert.True(index.Groups[4].IsExcluded); // A2 (TurnIndex 2) excluded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesNullTurnIndexAsync()
|
||||
{
|
||||
// Arrange — system messages (TurnIndex = null) should never be removed
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(0),
|
||||
minimumPreservedTurns: 0,
|
||||
target: _ => false);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — system message (TurnIndex null) always preserved
|
||||
Assert.True(result);
|
||||
Assert.False(index.Groups[0].IsExcluded); // System (TurnIndex null) preserved
|
||||
Assert.True(index.Groups[1].IsExcluded); // Q1 excluded
|
||||
Assert.True(index.Groups[2].IsExcluded); // A1 excluded
|
||||
}
|
||||
}
|
||||
+613
@@ -0,0 +1,613 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="SummarizationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class SummarizationCompactionStrategyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a mock <see cref="IChatClient"/> that returns the specified summary text.
|
||||
/// </summary>
|
||||
private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.")
|
||||
{
|
||||
Mock<IChatClient> mock = new();
|
||||
mock.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, summaryText)]));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 100000 tokens
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.TokensExceed(100000),
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(2, index.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSummarizesOldGroupsAsync()
|
||||
{
|
||||
// Arrange — always trigger, preserve 1 recent group
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Key facts from earlier."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First question"),
|
||||
new ChatMessage(ChatRole.Assistant, "First answer"),
|
||||
new ChatMessage(ChatRole.User, "Second question"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
|
||||
// Should have: summary + preserved recent group (Second question)
|
||||
Assert.Equal(2, included.Count);
|
||||
Assert.Contains("[Summary]", included[0].Text);
|
||||
Assert.Contains("Key facts from earlier.", included[0].Text);
|
||||
Assert.Equal("Second question", included[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Old question"),
|
||||
new ChatMessage(ChatRole.Assistant, "Old answer"),
|
||||
new ChatMessage(ChatRole.User, "Recent question"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
|
||||
Assert.Equal("You are helpful.", included[0].Text);
|
||||
Assert.Equal(ChatRole.System, included[0].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync()
|
||||
{
|
||||
// Arrange
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Summary text."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — summary should be inserted after system, before preserved group
|
||||
CompactionMessageGroup summaryGroup = index.Groups.First(g => g.Kind == CompactionGroupKind.Summary);
|
||||
Assert.NotNull(summaryGroup);
|
||||
Assert.Contains("[Summary]", summaryGroup.Messages[0].Text);
|
||||
Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(CompactionMessageGroup.SummaryPropertyKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncHandlesEmptyLlmResponseAsync()
|
||||
{
|
||||
// Arrange — LLM returns whitespace
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(" "),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — should use fallback text
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
Assert.Contains("[Summary unavailable]", included[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — preserve 5 but only 2 non-system groups
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 5);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncUsesCustomPromptAsync()
|
||||
{
|
||||
// Arrange — capture the messages sent to the chat client
|
||||
List<ChatMessage>? capturedMessages = null;
|
||||
Mock<IChatClient> mockClient = new();
|
||||
mockClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
capturedMessages = [.. msgs])
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Custom summary.")]));
|
||||
|
||||
const string CustomPrompt = "Summarize in bullet points only.";
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient.Object,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1,
|
||||
summarizationPrompt: CustomPrompt);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — the custom prompt should be the system message, followed by the original messages
|
||||
Assert.NotNull(capturedMessages);
|
||||
Assert.Equal(2, capturedMessages.Count);
|
||||
Assert.Equal(ChatRole.System, capturedMessages![0].Role);
|
||||
Assert.Equal(CustomPrompt, capturedMessages[0].Text);
|
||||
Assert.Equal(ChatRole.User, capturedMessages[1].Role);
|
||||
Assert.Equal("Q1", capturedMessages[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSetsExcludeReasonAsync()
|
||||
{
|
||||
// Arrange
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Old"),
|
||||
new ChatMessage(ChatRole.User, "New"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
CompactionMessageGroup excluded = index.Groups.First(g => g.IsExcluded);
|
||||
Assert.NotNull(excluded.ExcludeReason);
|
||||
Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTargetStopsMarkingEarlyAsync()
|
||||
{
|
||||
// Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion
|
||||
int exclusionCount = 0;
|
||||
bool TargetAfterOne(CompactionMessageIndex _) => ++exclusionCount >= 1;
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Partial summary."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1,
|
||||
target: TargetAfterOne);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — only 1 group should have been summarized (target met after first exclusion)
|
||||
int excludedCount = index.Groups.Count(g => g.IsExcluded);
|
||||
Assert.Equal(1, excludedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesMultipleRecentGroupsAsync()
|
||||
{
|
||||
// Arrange — preserve 2
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Summary."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 2);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — 2 oldest excluded, 2 newest preserved + 1 summary inserted
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
Assert.Equal(3, included.Count); // summary + Q2 + A2
|
||||
Assert.Contains("[Summary]", included[0].Text);
|
||||
Assert.Equal("Q2", included[1].Text);
|
||||
Assert.Equal("A2", included[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync()
|
||||
{
|
||||
// Arrange — system group between user/assistant groups to exercise skip logic in loop
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.System, "System note"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — summary inserted at 0, system group shifted to index 2
|
||||
Assert.True(result);
|
||||
Assert.Equal(CompactionGroupKind.Summary, index.Groups[0].Kind);
|
||||
Assert.Equal(CompactionGroupKind.System, index.Groups[2].Kind);
|
||||
Assert.False(index.Groups[2].IsExcluded); // System never excluded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync()
|
||||
{
|
||||
// Arrange — large MinimumPreserved so maxSummarizable is small, target never stops
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 3,
|
||||
target: _ => false);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
]);
|
||||
|
||||
// Act — should only summarize 6-3 = 3 groups (not all 6)
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — 3 preserved + 1 summary = 4 included
|
||||
Assert.True(result);
|
||||
Assert.Equal(4, index.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWithPreExcludedGroupAsync()
|
||||
{
|
||||
// Arrange — pre-exclude a group so the count and loop both must skip it
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
index.Groups[0].IsExcluded = true; // Pre-exclude Q1
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(index.Groups[0].IsExcluded); // Still excluded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWithEmptyTextMessageInGroupAsync()
|
||||
{
|
||||
// Arrange — a message with null text (FunctionCallContent) in a summarized group
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
];
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
|
||||
|
||||
// Act — the tool-call group's message has null text
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — compaction succeeded despite null text
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
#region Error resilience
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncLlmFailureRestoresGroupsAsync()
|
||||
{
|
||||
// Arrange — chat client throws a non-cancellation exception
|
||||
Mock<IChatClient> mockClient = new();
|
||||
mockClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Service unavailable"));
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient.Object,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
int originalGroupCount = index.Groups.Count;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — returns false, all groups restored to non-excluded
|
||||
Assert.False(result);
|
||||
Assert.Equal(originalGroupCount, index.Groups.Count);
|
||||
Assert.All(index.Groups, g => Assert.False(g.IsExcluded));
|
||||
Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncLlmFailurePreservesAllOriginalMessagesAsync()
|
||||
{
|
||||
// Arrange — verify that after failure, GetIncludedMessages returns all original messages
|
||||
Mock<IChatClient> mockClient = new();
|
||||
mockClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new HttpRequestException("Timeout"));
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient.Object,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
List<ChatMessage> originalIncluded = [.. index.GetIncludedMessages()];
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — all original messages still included
|
||||
List<ChatMessage> afterIncluded = [.. index.GetIncludedMessages()];
|
||||
Assert.Equal(originalIncluded.Count, afterIncluded.Count);
|
||||
for (int i = 0; i < originalIncluded.Count; i++)
|
||||
{
|
||||
Assert.Same(originalIncluded[i], afterIncluded[i]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncLlmFailureDoesNotInsertSummaryGroupAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockClient = new();
|
||||
mockClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("API error"));
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient.Object,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — no Summary group was inserted
|
||||
Assert.DoesNotContain(index.Groups, g => g.Kind == CompactionGroupKind.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCancellationPropagatesAsync()
|
||||
{
|
||||
// Arrange — OperationCanceledException should NOT be caught
|
||||
Mock<IChatClient> mockClient = new();
|
||||
mockClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException("Cancelled"));
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient.Object,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act & Assert — OperationCanceledException propagates
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(
|
||||
() => strategy.CompactAsync(index).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTaskCancellationPropagatesAsync()
|
||||
{
|
||||
// Arrange — TaskCanceledException (subclass of OperationCanceledException) should also propagate
|
||||
Mock<IChatClient> mockClient = new();
|
||||
mockClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new TaskCanceledException("Task cancelled"));
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient.Object,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act & Assert — TaskCanceledException propagates (inherits from OperationCanceledException)
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(
|
||||
() => strategy.CompactAsync(index).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncLlmFailureWithMultipleExcludedGroupsRestoresAllAsync()
|
||||
{
|
||||
// Arrange — multiple groups excluded before failure, all must be restored
|
||||
Mock<IChatClient> mockClient = new();
|
||||
mockClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Rate limited"));
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient.Object,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1,
|
||||
target: _ => false); // Never stop — exclude as many as possible
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.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"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — all non-system groups restored
|
||||
Assert.False(result);
|
||||
Assert.All(index.Groups, g => Assert.False(g.IsExcluded));
|
||||
Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason));
|
||||
Assert.Equal(6, index.IncludedGroupCount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="ToolResultCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class ToolResultCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 1000 tokens
|
||||
ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
|
||||
ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "What's the weather?"),
|
||||
toolCall,
|
||||
toolResult,
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCollapsesOldToolGroupsAsync()
|
||||
{
|
||||
// Arrange — always trigger
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, "Sunny and 72°F"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
// Q1 + collapsed tool summary + Q2
|
||||
Assert.Equal(3, included.Count);
|
||||
Assert.Equal("Q1", included[0].Text);
|
||||
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny and 72°F", included[1].Text);
|
||||
Assert.Equal("Q2", included[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesRecentToolGroupsAsync()
|
||||
{
|
||||
// Arrange — protect 2 recent non-system groups (the tool group + Q2)
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 3);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
|
||||
new ChatMessage(ChatRole.Tool, "Results"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — all groups are in the protected window, nothing to collapse
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("You are helpful.", included[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncExtractsMultipleToolNamesAsync()
|
||||
{
|
||||
// Arrange — assistant calls two tools
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
ChatMessage multiToolCall = new(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("c1", "get_weather"),
|
||||
new FunctionCallContent("c2", "search_docs"),
|
||||
]);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
multiToolCall,
|
||||
new ChatMessage(ChatRole.Tool, "Sunny"),
|
||||
new ChatMessage(ChatRole.Tool, "Found 3 docs"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
string collapsed = included[1].Text!;
|
||||
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\nsearch_docs:\n - Found 3 docs", collapsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncNoToolGroupsReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger fires but no tool groups to collapse
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 0);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync()
|
||||
{
|
||||
// Arrange — compound: tokens > 0 AND has tool calls
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
CompactionTriggers.All(
|
||||
CompactionTriggers.TokensExceed(0),
|
||||
CompactionTriggers.HasToolCalls()),
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTargetStopsCollapsingEarlyAsync()
|
||||
{
|
||||
// Arrange — 2 tool groups, target met after first collapse
|
||||
int collapseCount = 0;
|
||||
bool TargetAfterOne(CompactionMessageIndex _) => ++collapseCount >= 1;
|
||||
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1,
|
||||
target: TargetAfterOne);
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]),
|
||||
new ChatMessage(ChatRole.Tool, "result1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]),
|
||||
new ChatMessage(ChatRole.Tool, "result2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — only first tool group collapsed, second left intact
|
||||
Assert.True(result);
|
||||
|
||||
// Count collapsed tool groups (excluded with ToolCall kind)
|
||||
int collapsedToolGroups = 0;
|
||||
foreach (CompactionMessageGroup group in index.Groups)
|
||||
{
|
||||
if (group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall)
|
||||
{
|
||||
collapsedToolGroups++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(1, collapsedToolGroups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
|
||||
{
|
||||
// Arrange — pre-excluded and system groups in the enumeration
|
||||
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 0);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt"),
|
||||
new ChatMessage(ChatRole.User, "Q0"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "Result 1"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
];
|
||||
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
|
||||
// Pre-exclude the last user group
|
||||
index.Groups[index.Groups.Count - 1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — system never excluded, pre-excluded skipped
|
||||
Assert.True(result);
|
||||
Assert.False(index.Groups[0].IsExcluded); // System stays
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncDeduplicatesDuplicateToolNamesAsync()
|
||||
{
|
||||
// Arrange — same tool called multiple times
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("c1", "get_weather"),
|
||||
new FunctionCallContent("c2", "get_weather"),
|
||||
]),
|
||||
new ChatMessage(ChatRole.Tool, "Sunny"),
|
||||
new ChatMessage(ChatRole.Tool, "Rainy"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — duplicate names listed once with all results
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy", included[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncIncludesResultsFromFunctionResultContentAsync()
|
||||
{
|
||||
// Arrange — tool results provided as FunctionResultContent (matched by CallId)
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("c1", "get_weather"),
|
||||
new FunctionCallContent("c2", "search_docs"),
|
||||
]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny and 72°F")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Found 3 docs")]),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — results matched by CallId and included in summary
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny and 72°F\nsearch_docs:\n - Found 3 docs", included[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncDeduplicatesWithFunctionResultContentAsync()
|
||||
{
|
||||
// Arrange — same tool called multiple times with FunctionResultContent
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("c1", "get_weather"),
|
||||
new FunctionCallContent("c2", "get_weather"),
|
||||
new FunctionCallContent("c3", "search_docs"),
|
||||
]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Rainy")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c3", "Found 3 docs")]),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — duplicate tool name results listed under same key
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text);
|
||||
}
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class TruncationCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync()
|
||||
{
|
||||
// Arrange — always-trigger means always compact
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 1000 tokens, conversation is tiny
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
minimumPreservedGroups: 1,
|
||||
trigger: CompactionTriggers.TokensExceed(1000));
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync()
|
||||
{
|
||||
// Arrange — trigger on groups > 2
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
minimumPreservedGroups: 1,
|
||||
trigger: CompactionTriggers.GroupsExceed(2));
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
// Oldest 2 excluded, newest 2 kept
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// System message should be preserved
|
||||
Assert.False(groups.Groups[0].IsExcluded);
|
||||
Assert.Equal(CompactionGroupKind.System, groups.Groups[0].Kind);
|
||||
// Oldest non-system groups excluded
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.True(groups.Groups[2].IsExcluded);
|
||||
// Most recent kept
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
|
||||
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.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(CompactionGroupKind.ToolCall, groups.Groups[0].Kind);
|
||||
Assert.Equal(2, groups.Groups[0].Messages.Count);
|
||||
Assert.False(groups.Groups[1].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSetsExcludeReasonAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Old"),
|
||||
new ChatMessage(ChatRole.User, "New"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(groups.Groups[0].ExcludeReason);
|
||||
Assert.Contains("TruncationCompactionStrategy", groups.Groups[0].ExcludeReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Already excluded"),
|
||||
new ChatMessage(ChatRole.User, "Included 1"),
|
||||
new ChatMessage(ChatRole.User, "Included 2"),
|
||||
]);
|
||||
groups.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(groups.Groups[0].IsExcluded); // was already excluded
|
||||
Assert.True(groups.Groups[1].IsExcluded); // newly excluded
|
||||
Assert.False(groups.Groups[2].IsExcluded); // kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync()
|
||||
{
|
||||
// Arrange — keep 2 most recent
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 2);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncNothingToRemoveReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — preserve 5 but only 2 groups
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 5);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomTargetStopsEarlyAsync()
|
||||
{
|
||||
// Arrange — always trigger, custom target stops after 1 exclusion
|
||||
int targetChecks = 0;
|
||||
bool TargetAfterOne(CompactionMessageIndex _) => ++targetChecks >= 1;
|
||||
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
CompactionTriggers.Always,
|
||||
minimumPreservedGroups: 1,
|
||||
target: TargetAfterOne);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — only 1 group excluded (target met after first)
|
||||
Assert.True(result);
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.False(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncIncrementalStopsAtTargetAsync()
|
||||
{
|
||||
// Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2)
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
CompactionTriggers.GroupsExceed(2),
|
||||
minimumPreservedGroups: 1);
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act — 5 groups, trigger fires (5 > 2), compacts until groups <= 2
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — should stop at 2 included groups (not go all the way to 1)
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync()
|
||||
{
|
||||
// Arrange — target never stops (always false), so the loop must exit via removed >= maxRemovable
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 2, target: CompactionTriggers.Never);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — only 2 removed (maxRemovable = 4 - 2 = 2), 2 preserved
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
|
||||
{
|
||||
// Arrange — has excluded + system groups that the loop must skip
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
// Pre-exclude one group
|
||||
groups.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — system preserved, pre-excluded skipped, A1 removed, Q2 preserved
|
||||
Assert.True(result);
|
||||
Assert.False(groups.Groups[0].IsExcluded); // System
|
||||
Assert.True(groups.Groups[1].IsExcluded); // Pre-excluded Q1
|
||||
Assert.True(groups.Groups[2].IsExcluded); // Newly excluded A1
|
||||
Assert.False(groups.Groups[3].IsExcluded); // Preserved Q2
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -263,6 +264,25 @@ class SerializationMixin:
|
||||
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = set()
|
||||
INJECTABLE: ClassVar[set[str]] = set()
|
||||
_SHALLOW_COPY_FIELDS: ClassVar[set[str]] = {"raw_representation"}
|
||||
|
||||
def __deepcopy__(self, memo: dict[int, Any]) -> SerializationMixin:
|
||||
"""Create a deep copy, preserving ``_SHALLOW_COPY_FIELDS`` by reference.
|
||||
|
||||
Fields listed in ``_SHALLOW_COPY_FIELDS`` may contain LLM SDK objects
|
||||
(e.g., proto/gRPC responses) that are not safe to deep-copy. They are
|
||||
kept as shallow references in the copy; all other attributes are
|
||||
deep-copied normally.
|
||||
"""
|
||||
cls = type(self)
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
for k, v in self.__dict__.items():
|
||||
if k in cls._SHALLOW_COPY_FIELDS:
|
||||
object.__setattr__(result, k, v)
|
||||
else:
|
||||
object.__setattr__(result, k, copy.deepcopy(v, memo))
|
||||
return result
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Convert the instance and any nested objects to a dictionary.
|
||||
|
||||
@@ -445,6 +445,8 @@ class Content:
|
||||
`Content.from_uri()`, etc. to create instances.
|
||||
"""
|
||||
|
||||
_SHALLOW_COPY_FIELDS: ClassVar[set[str]] = {"raw_representation"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
type: ContentType,
|
||||
@@ -546,6 +548,23 @@ class Content:
|
||||
self.approved = approved
|
||||
self.consent_link = consent_link
|
||||
|
||||
def __deepcopy__(self, memo: dict[int, Any]) -> Content:
|
||||
"""Create a deep copy, preserving ``_SHALLOW_COPY_FIELDS`` by reference.
|
||||
|
||||
Fields listed in ``_SHALLOW_COPY_FIELDS`` may contain LLM SDK objects
|
||||
(e.g., proto/gRPC responses) that are not safe to deep-copy.
|
||||
"""
|
||||
cls = type(self)
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
shallow = cls._SHALLOW_COPY_FIELDS
|
||||
for k, v in self.__dict__.items():
|
||||
if k in shallow:
|
||||
object.__setattr__(result, k, v)
|
||||
else:
|
||||
object.__setattr__(result, k, deepcopy(v, memo))
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_text(
|
||||
cls: type[ContentT],
|
||||
|
||||
@@ -427,3 +427,103 @@ class TestSerializationMixin:
|
||||
|
||||
assert obj.options["existing"] == "value"
|
||||
assert obj.options["injected"] == "option"
|
||||
|
||||
def test_deepcopy_preserves_shallow_copy_fields_by_reference(self):
|
||||
"""Test that deepcopy keeps _SHALLOW_COPY_FIELDS fields as shallow references."""
|
||||
import copy
|
||||
|
||||
class NonCopyable:
|
||||
def __deepcopy__(self, memo):
|
||||
raise TypeError("cannot deepcopy")
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
_SHALLOW_COPY_FIELDS = {"raw_representation", "other_opaque"}
|
||||
|
||||
def __init__(self, items: list, raw_representation: Any = None, other_opaque: Any = None):
|
||||
self.items = items
|
||||
self.raw_representation = raw_representation
|
||||
self.other_opaque = other_opaque
|
||||
|
||||
raw = NonCopyable()
|
||||
opaque = NonCopyable()
|
||||
original_items = ["a", "b"]
|
||||
obj = TestClass(items=original_items, raw_representation=raw, other_opaque=opaque)
|
||||
cloned = copy.deepcopy(obj)
|
||||
|
||||
# _SHALLOW_COPY_FIELDS fields should be the same object (shallow copy)
|
||||
assert cloned.raw_representation is raw
|
||||
assert cloned.other_opaque is opaque
|
||||
# Normal attributes should be independent copies
|
||||
assert cloned.items is not original_items
|
||||
assert cloned.items == ["a", "b"]
|
||||
|
||||
def test_deepcopy_deep_copies_non_shallow_copy_fields(self):
|
||||
"""Test that deepcopy fully copies fields not in _SHALLOW_COPY_FIELDS."""
|
||||
import copy
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
_SHALLOW_COPY_FIELDS = {"raw_representation"}
|
||||
|
||||
def __init__(self, items: list, raw_representation: Any = None):
|
||||
self.items = items
|
||||
self.raw_representation = raw_representation
|
||||
|
||||
original_list = ["a", "b"]
|
||||
obj = TestClass(items=original_list, raw_representation="raw")
|
||||
cloned = copy.deepcopy(obj)
|
||||
|
||||
# list should be a new object
|
||||
assert cloned.items is not original_list
|
||||
assert cloned.items == ["a", "b"]
|
||||
# raw_representation should be the same object
|
||||
assert cloned.raw_representation is obj.raw_representation
|
||||
|
||||
def test_deepcopy_deep_copies_default_exclude_fields(self):
|
||||
"""Test that DEFAULT_EXCLUDE fields are deep-copied unless also in _SHALLOW_COPY_FIELDS."""
|
||||
import copy
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
DEFAULT_EXCLUDE = {"additional_properties"}
|
||||
|
||||
def __init__(self, items: list, additional_properties: dict | None = None):
|
||||
self.items = items
|
||||
self.additional_properties = additional_properties or {}
|
||||
|
||||
original_props = {"key": "value"}
|
||||
obj = TestClass(items=["a"], additional_properties=original_props)
|
||||
cloned = copy.deepcopy(obj)
|
||||
|
||||
# DEFAULT_EXCLUDE field should be deep-copied (independent copy)
|
||||
assert cloned.additional_properties is not original_props
|
||||
assert cloned.additional_properties == {"key": "value"}
|
||||
|
||||
def test_deepcopy_shallow_copy_fields_override_default_exclude(self):
|
||||
"""Test that _SHALLOW_COPY_FIELDS controls deepcopy independently of DEFAULT_EXCLUDE."""
|
||||
import copy
|
||||
|
||||
class NonCopyable:
|
||||
def __deepcopy__(self, memo):
|
||||
raise TypeError("cannot deepcopy")
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
DEFAULT_EXCLUDE = {"opaque", "additional_properties"}
|
||||
_SHALLOW_COPY_FIELDS = {"opaque"}
|
||||
|
||||
def __init__(self, items: list, opaque: Any = None, additional_properties: dict | None = None):
|
||||
self.items = items
|
||||
self.opaque = opaque
|
||||
self.additional_properties = additional_properties or {}
|
||||
|
||||
opaque = NonCopyable()
|
||||
original_props = {"key": "value"}
|
||||
obj = TestClass(items=["a"], opaque=opaque, additional_properties=original_props)
|
||||
cloned = copy.deepcopy(obj)
|
||||
|
||||
# Field in both DEFAULT_EXCLUDE and _SHALLOW_COPY_FIELDS: shallow-copied
|
||||
assert cloned.opaque is opaque
|
||||
# Field in DEFAULT_EXCLUDE only: deep-copied
|
||||
assert cloned.additional_properties is not original_props
|
||||
assert cloned.additional_properties == {"key": "value"}
|
||||
# Normal field: deep-copied
|
||||
assert cloned.items is not obj.items
|
||||
assert cloned.items == ["a"]
|
||||
|
||||
@@ -1860,6 +1860,170 @@ def test_agent_run_response_update_all_content_types():
|
||||
assert update_str.role == "user"
|
||||
|
||||
|
||||
# region DeepCopy
|
||||
|
||||
|
||||
class _NonCopyableRaw:
|
||||
"""Simulates an LLM SDK response object that cannot be deep-copied (e.g., proto/gRPC)."""
|
||||
|
||||
def __deepcopy__(self, memo: dict) -> Any:
|
||||
raise TypeError("Cannot deepcopy this object")
|
||||
|
||||
|
||||
def test_content_deepcopy_preserves_raw_representation():
|
||||
"""Test that deepcopy of Content keeps raw_representation by reference."""
|
||||
import copy
|
||||
|
||||
raw = _NonCopyableRaw()
|
||||
content = Content.from_text("hello", raw_representation=raw)
|
||||
|
||||
cloned = copy.deepcopy(content)
|
||||
|
||||
assert cloned.text == "hello"
|
||||
assert cloned.raw_representation is raw
|
||||
assert cloned.additional_properties is not content.additional_properties
|
||||
|
||||
|
||||
def test_message_deepcopy_preserves_raw_representation():
|
||||
"""Test that deepcopy of Message keeps raw_representation by reference."""
|
||||
import copy
|
||||
|
||||
raw = _NonCopyableRaw()
|
||||
msg = Message("assistant", ["hello"], raw_representation=raw)
|
||||
|
||||
cloned = copy.deepcopy(msg)
|
||||
|
||||
assert cloned.text == "hello"
|
||||
assert cloned.raw_representation is raw
|
||||
assert cloned.contents is not msg.contents
|
||||
|
||||
|
||||
def test_agent_response_deepcopy_preserves_raw_representation():
|
||||
"""Test that deepcopy of AgentResponse keeps raw_representation by reference."""
|
||||
import copy
|
||||
|
||||
raw = _NonCopyableRaw()
|
||||
response = AgentResponse(
|
||||
messages=[Message("assistant", ["test"])],
|
||||
raw_representation=raw,
|
||||
)
|
||||
|
||||
cloned = copy.deepcopy(response)
|
||||
|
||||
assert cloned.text == "test"
|
||||
assert cloned.raw_representation is raw
|
||||
assert cloned.messages is not response.messages
|
||||
|
||||
|
||||
def test_chat_response_deepcopy_preserves_raw_representation():
|
||||
"""Test that deepcopy of ChatResponse keeps raw_representation by reference."""
|
||||
import copy
|
||||
|
||||
raw = _NonCopyableRaw()
|
||||
response = ChatResponse(
|
||||
messages=[Message("assistant", ["test"])],
|
||||
raw_representation=raw,
|
||||
)
|
||||
|
||||
cloned = copy.deepcopy(response)
|
||||
|
||||
assert cloned.text == "test"
|
||||
assert cloned.raw_representation is raw
|
||||
assert cloned.messages is not response.messages
|
||||
|
||||
|
||||
def test_chat_response_update_deepcopy_preserves_raw_representation():
|
||||
"""Test that deepcopy of ChatResponseUpdate keeps raw_representation by reference."""
|
||||
import copy
|
||||
|
||||
raw = _NonCopyableRaw()
|
||||
update = ChatResponseUpdate(
|
||||
contents=[Content.from_text("hello")],
|
||||
role="assistant",
|
||||
raw_representation=raw,
|
||||
)
|
||||
|
||||
cloned = copy.deepcopy(update)
|
||||
|
||||
assert cloned.text == "hello"
|
||||
assert cloned.raw_representation is raw
|
||||
assert cloned.contents is not update.contents
|
||||
|
||||
|
||||
def test_agent_response_update_deepcopy_preserves_raw_representation():
|
||||
"""Test that deepcopy of AgentResponseUpdate keeps raw_representation by reference."""
|
||||
import copy
|
||||
|
||||
raw = _NonCopyableRaw()
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text("hello")],
|
||||
role="assistant",
|
||||
raw_representation=raw,
|
||||
)
|
||||
|
||||
cloned = copy.deepcopy(update)
|
||||
|
||||
assert cloned.text == "hello"
|
||||
assert cloned.raw_representation is raw
|
||||
assert cloned.contents is not update.contents
|
||||
|
||||
|
||||
def test_nested_deepcopy_preserves_raw_representation():
|
||||
"""Test that deepcopy of an AgentResponse with nested Message raw_representations works."""
|
||||
import copy
|
||||
|
||||
raw_msg = _NonCopyableRaw()
|
||||
raw_response = _NonCopyableRaw()
|
||||
response = AgentResponse(
|
||||
messages=[Message("assistant", ["hello"], raw_representation=raw_msg)],
|
||||
raw_representation=raw_response,
|
||||
)
|
||||
|
||||
cloned = copy.deepcopy(response)
|
||||
|
||||
assert cloned.raw_representation is raw_response
|
||||
assert cloned.messages[0].raw_representation is raw_msg
|
||||
assert cloned.messages is not response.messages
|
||||
assert cloned.text == "hello"
|
||||
|
||||
|
||||
def test_content_deepcopy_shallow_copy_fields_identity():
|
||||
"""Test that Content._SHALLOW_COPY_FIELDS fields are identity-preserved while others are deep-copied."""
|
||||
import copy
|
||||
|
||||
raw = _NonCopyableRaw()
|
||||
content = Content.from_text("hello", raw_representation=raw)
|
||||
content.additional_properties["key"] = "value"
|
||||
|
||||
cloned = copy.deepcopy(content)
|
||||
|
||||
# _SHALLOW_COPY_FIELDS (raw_representation) should be same object
|
||||
assert cloned.raw_representation is raw
|
||||
# Non-shallow fields should be independent deep copies
|
||||
assert cloned.additional_properties is not content.additional_properties
|
||||
assert cloned.additional_properties == {"key": "value"}
|
||||
|
||||
|
||||
def test_chat_response_deepcopy_deep_copies_additional_properties():
|
||||
"""Test that ChatResponse deepcopy deep-copies additional_properties despite it being in DEFAULT_EXCLUDE."""
|
||||
import copy
|
||||
|
||||
response = ChatResponse(
|
||||
messages=[Message("assistant", ["test"])],
|
||||
additional_properties={"key": [1, 2, 3]},
|
||||
)
|
||||
|
||||
cloned = copy.deepcopy(response)
|
||||
|
||||
# additional_properties is in DEFAULT_EXCLUDE for serialization but not in _SHALLOW_COPY_FIELDS,
|
||||
# so it should be deep-copied (independent copy)
|
||||
assert cloned.additional_properties is not response.additional_properties
|
||||
assert cloned.additional_properties == {"key": [1, 2, 3]}
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Serialization
|
||||
|
||||
|
||||
|
||||
@@ -383,3 +383,60 @@ async def test_agent_executor_run_with_messages_kwarg_does_not_raise() -> None:
|
||||
result = await workflow.run("hello", messages=["stale"])
|
||||
assert result is not None
|
||||
assert agent.call_count == 1
|
||||
|
||||
|
||||
class _NonCopyableRaw:
|
||||
"""Simulates an LLM SDK response object that cannot be deep-copied (e.g., proto/gRPC)."""
|
||||
|
||||
def __deepcopy__(self, memo: dict) -> Any:
|
||||
raise TypeError("Cannot deepcopy this object")
|
||||
|
||||
|
||||
class _AgentWithRawRepr(BaseAgent):
|
||||
"""Agent that returns responses with a non-copyable raw_representation."""
|
||||
|
||||
def __init__(self, raw: Any, **kwargs: Any):
|
||||
super().__init__(**kwargs)
|
||||
self._raw = raw
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: str | Message | list[str] | list[Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
async def _run() -> AgentResponse:
|
||||
return AgentResponse(
|
||||
messages=[Message("assistant", [f"reply from {self.name}"])],
|
||||
raw_representation=self._raw,
|
||||
)
|
||||
|
||||
return _run()
|
||||
|
||||
|
||||
async def test_agent_executor_workflow_with_non_copyable_raw_representation() -> None:
|
||||
"""Workflow should complete when AgentResponse contains a raw_representation that cannot be deep-copied."""
|
||||
raw = _NonCopyableRaw()
|
||||
|
||||
agent_a = _AgentWithRawRepr(raw=raw, id="a", name="AgentA")
|
||||
agent_b = _CountingAgent(id="b", name="AgentB")
|
||||
|
||||
exec_a = AgentExecutor(agent_a, id="exec_a")
|
||||
exec_b = AgentExecutor(agent_b, id="exec_b")
|
||||
|
||||
workflow = SequentialBuilder(participants=[exec_a, exec_b]).build()
|
||||
events = await workflow.run("hello")
|
||||
|
||||
completed = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
|
||||
completed_a = [e for e in completed if e.executor_id == "exec_a"]
|
||||
|
||||
assert len(completed_a) == 1
|
||||
assert completed_a[0].data is not None
|
||||
|
||||
# The yielded AgentResponse should preserve its raw_representation reference
|
||||
agent_responses = [d for d in completed_a[0].data if isinstance(d, AgentResponse)]
|
||||
assert len(agent_responses) > 0
|
||||
assert agent_responses[0].text == "reply from AgentA"
|
||||
assert agent_responses[0].raw_representation is raw
|
||||
|
||||
@@ -29,7 +29,7 @@ Add Purview when you need to:
|
||||
|
||||
- Microsoft Azure subscription with Microsoft Purview configured.
|
||||
- Microsoft 365 subscription with an E5 license and pay-as-you-go billing setup.
|
||||
- For testing, you can use a Microsoft 365 Developer Program tenant. For more information, see [Join the Microsoft 365 Developer Program](https://developer.microsoft.com/en-us/microsoft-365/dev-program).
|
||||
- For testing, you can use a Microsoft 365 Developer Program tenant. For more information, see [Join the Microsoft 365 Developer Program](https://learn.microsoft.com/en-us/office/developer-program/microsoft-365-developer-program).
|
||||
|
||||
### Authentication
|
||||
|
||||
|
||||
Reference in New Issue
Block a user