.NET: Add context window size compaction strategy for harness (#5304)

* Add context window size compaction strategy for harness

* Apply suggestions from code review

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

* Address PR comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
westey
2026-04-16 16:44:45 +01:00
committed by GitHub
Unverified
parent 9d89353818
commit d0ac1d83bc
6 changed files with 468 additions and 20 deletions
@@ -19,7 +19,9 @@ public static class HarnessConsole
/// <param name="agent">The agent to interact with.</param>
/// <param name="title">The title displayed in the console header.</param>
/// <param name="userPrompt">A short prompt to the user, displayed below the title.</param>
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt)
/// <param name="maxContextWindowTokens">Optional max context window size in tokens. When set, usage is displayed as a percentage.</param>
/// <param name="maxOutputTokens">Optional max output tokens. Used with <paramref name="maxContextWindowTokens"/> to show input/output budget breakdown.</param>
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, int? maxContextWindowTokens = null, int? maxOutputTokens = null)
{
var todoProvider = agent.GetService<TodoProvider>();
var modeProvider = agent.GetService<AgentModeProvider>();
@@ -46,7 +48,7 @@ public static class HarnessConsole
}
else
{
await StreamAgentResponseAsync(agent, session, modeProvider, userInput);
await StreamAgentResponseAsync(agent, session, modeProvider, userInput, maxContextWindowTokens, maxOutputTokens);
}
WritePrompt(modeProvider, session);
@@ -57,7 +59,7 @@ public static class HarnessConsole
System.Console.WriteLine("Goodbye!");
}
private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput)
private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput, int? maxContextWindowTokens, int? maxOutputTokens)
{
string mode = modeProvider?.GetMode(session) ?? "unknown";
System.Console.ForegroundColor = GetModeColor(mode);
@@ -106,6 +108,37 @@ public static class HarnessConsole
System.Console.ForegroundColor = GetModeColor(mode);
}
else if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
{
await spinner.StopAsync();
if (!hasTextOutput)
{
System.Console.Write("\n");
hasTextOutput = true;
hasReceivedAnyText = true;
}
System.Console.ForegroundColor = ConsoleColor.DarkMagenta;
System.Console.Write(reasoning.Text);
System.Console.ForegroundColor = GetModeColor(mode);
}
else if (content is UsageContent usage)
{
await spinner.StopAsync();
System.Console.ForegroundColor = ConsoleColor.DarkGray;
System.Console.Write("\n\n 📊 Tokens");
if (usage.Details is not null)
{
WriteUsageBreakdown(usage.Details, maxContextWindowTokens, maxOutputTokens);
}
else
{
System.Console.Write(" —");
}
System.Console.ForegroundColor = GetModeColor(mode);
hasTextOutput = false;
}
}
if (string.IsNullOrEmpty(update.Text))
@@ -136,7 +169,7 @@ public static class HarnessConsole
{
await spinner.StopAsync();
System.Console.ForegroundColor = ConsoleColor.Red;
System.Console.Write($"\n ❌ Stream error: {ex.GetType().Name}: {ex.Message}");
System.Console.Write($"\n ❌ Stream error: {ex.GetType().Name}:\n{ex}");
}
await spinner.StopAsync();
@@ -190,7 +223,7 @@ public static class HarnessConsole
catch (ArgumentException ex)
{
System.Console.ForegroundColor = ConsoleColor.Red;
System.Console.WriteLine($"\n {ex.Message}\n");
System.Console.WriteLine($"\n {ex}\n");
System.Console.ResetColor();
}
}
@@ -237,6 +270,38 @@ public static class HarnessConsole
System.Console.WriteLine();
}
private static void WriteUsageBreakdown(UsageDetails details, int? maxContextWindowTokens, int? maxOutputTokens)
{
int? inputBudget = (maxContextWindowTokens is not null && maxOutputTokens is not null)
? maxContextWindowTokens.Value - maxOutputTokens.Value
: null;
System.Console.Write(" — input: ");
WriteTokenCount(details.InputTokenCount, inputBudget);
System.Console.Write(" | output: ");
WriteTokenCount(details.OutputTokenCount, maxOutputTokens);
System.Console.Write(" | total: ");
WriteTokenCount(details.TotalTokenCount, maxContextWindowTokens);
}
private static void WriteTokenCount(long? count, int? budget)
{
if (count is null)
{
System.Console.Write("—");
return;
}
System.Console.Write($"{count.Value:N0}");
if (budget is not null && budget.Value > 0)
{
double pct = (double)count.Value / budget.Value * 100;
System.Console.Write($"/{budget.Value:N0} ({pct:F1}%)");
}
}
private static ConsoleColor GetModeColor(string mode) => mode switch
{
AgentModeProvider.PlanMode => ConsoleColor.Cyan,
@@ -13,7 +13,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
@@ -11,28 +11,40 @@
// exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using Azure.AI.Projects;
using System.ClientModel.Primitives;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
using SampleApp;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
// Create the Azure AI Project client and get an IChatClient with stored output disabled
// Create a compaction strategy based on the model's context window.
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_050_000,
maxOutputTokens: 128_000);
// Create an OpenAIClient that communicates with the Foundry responses service and get an IChatClient with stored output disabled
// so that chat history is managed locally by the agent framework.
// 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.
var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
IChatClient chatClient = aiProjectClient
.GetProjectOpenAIClient()
.GetProjectResponsesClient()
.AsIChatClient(deploymentName);
OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint) };
IChatClient chatClient = new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsBuilder()
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
.Build();
// Create web browsing tools for downloading and converting HTML pages to markdown.
var webBrowsingTools = new WebBrowsingTools();
@@ -79,6 +91,10 @@ AIAgent agent = new ChatClientAgent(
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
AIContextProviders = [new TodoProvider(), new AgentModeProvider()],
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(),
}),
ChatOptions = new ChatOptions
{
// Set a high token limit for long research tasks with many tool calls and long outputs.
@@ -86,9 +102,9 @@ AIAgent agent = new ChatClientAgent(
MaxOutputTokens = 128_000,
Instructions = instructions,
Reasoning = new() { Effort = ReasoningEffort.High },
Tools = [FoundryAITool.CreateWebSearchTool(), .. webBrowsingTools.Tools],
Tools = [ResponseTool.CreateWebSearchTool().AsAITool(), .. webBrowsingTools.Tools],
},
});
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.");
await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: 1_050_000, maxOutputTokens: 128_000);
@@ -5,7 +5,7 @@ This sample demonstrates how to use a `ChatClientAgent` with the Harness `AICont
Key features showcased:
- **ChatClientAgent** — configured directly with Harness providers for planning and task management
- **Web Search** — the agent can search the web for current information via `FoundryAITool.CreateWebSearchTool()`
- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()`
- **TodoProvider** — the agent creates and manages a todo list to track research questions
- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question)
- **Interactive conversation** — you can review the agent's plan, provide feedback, and approve before execution begins
@@ -25,8 +25,8 @@ Before running this sample, ensure you have:
Set the following environment variables:
```bash
# Required: Your Azure AI Foundry project endpoint
export AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name"
# Required: Your Azure AI Foundry OpenAI endpoint
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
# Optional: Model deployment name (defaults to gpt-5.4)
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4"
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
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 derives token thresholds from a model's context window size
/// and maximum output tokens, applying a two-phase compaction pipeline:
/// <list type="number">
/// <item><description><b>Tool result eviction</b> (<see cref="ToolResultCompactionStrategy"/>) — collapses old tool call groups
/// into concise summaries when the token count exceeds the <see cref="ToolEvictionThreshold"/>.</description></item>
/// <item><description><b>Truncation</b> (<see cref="TruncationCompactionStrategy"/>) — removes the oldest non-system message groups
/// when the token count exceeds the <see cref="TruncationThreshold"/>.</description></item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// The <b>input budget</b> is defined as <c>maxContextWindowTokens - maxOutputTokens</c>, representing
/// the maximum number of tokens available for the conversation input (including system messages, tools, and history).
/// </para>
/// <para>
/// This strategy is a convenience wrapper around <see cref="PipelineCompactionStrategy"/> that automates
/// threshold calculation from model specifications.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class ContextWindowCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default fraction of the input budget at which tool result eviction triggers.
/// </summary>
public const double DefaultToolEvictionThreshold = 0.5;
/// <summary>
/// The default fraction of the input budget at which truncation triggers.
/// </summary>
public const double DefaultTruncationThreshold = 0.8;
private readonly PipelineCompactionStrategy _pipeline;
/// <summary>
/// Initializes a new instance of the <see cref="ContextWindowCompactionStrategy"/> class.
/// </summary>
/// <param name="maxContextWindowTokens">
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
/// </param>
/// <param name="maxOutputTokens">
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
/// </param>
/// <param name="toolEvictionThreshold">
/// The fraction of the input budget (0.0, 1.0] at which tool result eviction triggers.
/// Defaults to <see cref="DefaultToolEvictionThreshold"/> (0.5).
/// </param>
/// <param name="truncationThreshold">
/// The fraction of the input budget (0.0, 1.0] at which truncation triggers.
/// Defaults to <see cref="DefaultTruncationThreshold"/> (0.8).
/// Must be greater than or equal to <paramref name="toolEvictionThreshold"/>.
/// </param>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="maxContextWindowTokens"/> is not positive, or
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>, or
/// <paramref name="toolEvictionThreshold"/> or <paramref name="truncationThreshold"/> is not in (0.0, 1.0], or
/// <paramref name="truncationThreshold"/> is less than <paramref name="toolEvictionThreshold"/>.
/// </exception>
public ContextWindowCompactionStrategy(
int maxContextWindowTokens,
int maxOutputTokens,
double toolEvictionThreshold = DefaultToolEvictionThreshold,
double truncationThreshold = DefaultTruncationThreshold)
: base(CompactionTriggers.Always)
{
Throw.IfLessThanOrEqual(maxContextWindowTokens, 0);
Throw.IfLessThan(maxOutputTokens, 0);
Throw.IfGreaterThanOrEqual(maxOutputTokens, maxContextWindowTokens);
ValidateThreshold(toolEvictionThreshold, nameof(toolEvictionThreshold));
ValidateThreshold(truncationThreshold, nameof(truncationThreshold));
if (truncationThreshold < toolEvictionThreshold)
{
throw new ArgumentOutOfRangeException(nameof(truncationThreshold), truncationThreshold,
$"Truncation threshold ({truncationThreshold}) must be greater than or equal to tool eviction threshold ({toolEvictionThreshold}).");
}
this.MaxContextWindowTokens = maxContextWindowTokens;
this.MaxOutputTokens = maxOutputTokens;
this.InputBudgetTokens = maxContextWindowTokens - maxOutputTokens;
this.ToolEvictionThreshold = toolEvictionThreshold;
this.TruncationThreshold = truncationThreshold;
int toolEvictionTokens = (int)(this.InputBudgetTokens * toolEvictionThreshold);
int truncationTokens = (int)(this.InputBudgetTokens * truncationThreshold);
this._pipeline = new PipelineCompactionStrategy(
new ToolResultCompactionStrategy(
trigger: CompactionTriggers.TokensExceed(toolEvictionTokens),
minimumPreservedGroups: 2),
new TruncationCompactionStrategy(
trigger: CompactionTriggers.TokensExceed(truncationTokens),
minimumPreservedGroups: 2));
}
/// <summary>
/// Gets the maximum context window size in tokens.
/// </summary>
public int MaxContextWindowTokens { get; }
/// <summary>
/// Gets the maximum output tokens per response.
/// </summary>
public int MaxOutputTokens { get; }
/// <summary>
/// Gets the computed input budget in tokens (<see cref="MaxContextWindowTokens"/> minus <see cref="MaxOutputTokens"/>).
/// </summary>
public int InputBudgetTokens { get; }
/// <summary>
/// Gets the fraction of the input budget at which tool result eviction triggers.
/// </summary>
public double ToolEvictionThreshold { get; }
/// <summary>
/// Gets the fraction of the input budget at which truncation triggers.
/// </summary>
public double TruncationThreshold { get; }
/// <inheritdoc/>
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
return await this._pipeline.CompactAsync(index, logger, cancellationToken).ConfigureAwait(false);
}
private static void ValidateThreshold(double value, string paramName)
{
if (value is <= 0.0 or > 1.0)
{
throw new ArgumentOutOfRangeException(paramName, value, "Threshold must be in the range (0.0, 1.0].");
}
}
}
@@ -0,0 +1,219 @@
// 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;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ContextWindowCompactionStrategy"/> class.
/// </summary>
public class ContextWindowCompactionStrategyTests
{
[Fact]
public void Constructor_ValidParameters_SetsProperties()
{
// Arrange & Act
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_050_000,
maxOutputTokens: 128_000);
// Assert
Assert.Equal(1_050_000, strategy.MaxContextWindowTokens);
Assert.Equal(128_000, strategy.MaxOutputTokens);
Assert.Equal(922_000, strategy.InputBudgetTokens);
Assert.Equal(ContextWindowCompactionStrategy.DefaultToolEvictionThreshold, strategy.ToolEvictionThreshold);
Assert.Equal(ContextWindowCompactionStrategy.DefaultTruncationThreshold, strategy.TruncationThreshold);
}
[Fact]
public void Constructor_CustomThresholds_SetsProperties()
{
// Arrange & Act
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_000_000,
maxOutputTokens: 100_000,
toolEvictionThreshold: 0.3,
truncationThreshold: 0.6);
// Assert
Assert.Equal(900_000, strategy.InputBudgetTokens);
Assert.Equal(0.3, strategy.ToolEvictionThreshold);
Assert.Equal(0.6, strategy.TruncationThreshold);
}
[Theory]
[InlineData(0, 100)] // maxContextWindowTokens <= 0
[InlineData(-1, 100)] // maxContextWindowTokens negative
public void Constructor_InvalidContextWindow_Throws(int contextWindow, int maxOutput)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(contextWindow, maxOutput));
}
[Theory]
[InlineData(1000, -1)] // maxOutputTokens negative
[InlineData(1000, 1000)] // maxOutputTokens == contextWindow
[InlineData(1000, 1001)] // maxOutputTokens > contextWindow
public void Constructor_InvalidOutputTokens_Throws(int contextWindow, int maxOutput)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(contextWindow, maxOutput));
}
[Theory]
[InlineData(0.0)] // Zero threshold
[InlineData(-0.1)] // Negative threshold
[InlineData(1.1)] // Over 1.0
public void Constructor_InvalidToolEvictionThreshold_Throws(double threshold)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(1000, 100, toolEvictionThreshold: threshold));
}
[Theory]
[InlineData(0.0)] // Zero threshold
[InlineData(-0.1)] // Negative threshold
[InlineData(1.1)] // Over 1.0
public void Constructor_InvalidTruncationThreshold_Throws(double threshold)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(1000, 100, truncationThreshold: threshold));
}
[Fact]
public void Constructor_TruncationBelowToolEviction_Throws()
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(1000, 100, toolEvictionThreshold: 0.8, truncationThreshold: 0.5));
}
[Fact]
public async Task CompactAsync_BelowToolEvictionThreshold_NoCompactionAsync()
{
// Arrange — input budget = 900 tokens, tool eviction at 450, truncation at 720
// A few short messages should be well below any threshold.
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1000,
maxOutputTokens: 100);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi there!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsync_AboveTruncationThreshold_TruncatesOldestAsync()
{
// Arrange — use a budget of 5 tokens with truncation at 80% = 4 token threshold.
// Even the shortest messages will exceed this, ensuring truncation fires.
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 10,
maxOutputTokens: 5,
toolEvictionThreshold: 0.5,
truncationThreshold: 0.8);
// Verify internal budget calculation
Assert.Equal(5, strategy.InputBudgetTokens);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First user message"),
new ChatMessage(ChatRole.Assistant, "First response"),
new ChatMessage(ChatRole.User, "Second user message"),
new ChatMessage(ChatRole.Assistant, "Second response"),
]);
int groupsBefore = index.IncludedGroupCount;
int tokensBefore = index.IncludedTokenCount;
// Verify tokens actually exceed the truncation threshold (80% of 5 = 4)
Assert.True(tokensBefore > 4, $"Expected tokens > 4 but got {tokensBefore}");
Assert.True(groupsBefore > 1, $"Expected groups > 1 but got {groupsBefore}");
// Act
bool result = await strategy.CompactAsync(index);
// Assert — with tokens well above a 4-token threshold, truncation should fire
Assert.True(result, $"Expected compaction to occur. Tokens before: {tokensBefore}, groups before: {groupsBefore}, NonSystemGroups: {index.IncludedNonSystemGroupCount}");
Assert.True(index.IncludedGroupCount < groupsBefore);
}
[Fact]
public async Task CompactAsync_ToolCallsAboveEvictionThreshold_CollapsesToolCallsAsync()
{
// Arrange — very small budget so tool eviction fires.
// Input budget = 5, tool eviction at 50% = 2 token threshold.
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 10,
maxOutputTokens: 5,
toolEvictionThreshold: 0.5,
truncationThreshold: 0.9);
// Build messages with a tool call group: assistant with FunctionCallContent + tool result
var assistantMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_data", arguments: new Dictionary<string, object?> { ["query"] = "test" })]);
var toolResultMessage = new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Here is a long result with many words to ensure we exceed the token threshold")]);
var userMessage = new ChatMessage(ChatRole.User, "What did you find?");
var assistantResponse = new ChatMessage(ChatRole.Assistant, "Based on the results I found information.");
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
assistantMessage,
toolResultMessage,
userMessage,
assistantResponse,
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — compaction should succeed for tool calls above the eviction threshold.
// Do not assert on IncludedTokenCount because tool-result compaction preserves content
// in summary form and tokenization can make the count stay the same or increase.
Assert.True(result);
}
[Fact]
public void Constructor_EqualThresholds_Succeeds()
{
// Arrange & Act — truncation == tool eviction should be valid
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1000,
maxOutputTokens: 100,
toolEvictionThreshold: 0.7,
truncationThreshold: 0.7);
// Assert
Assert.Equal(0.7, strategy.ToolEvictionThreshold);
Assert.Equal(0.7, strategy.TruncationThreshold);
}
[Fact]
public void Constructor_ZeroMaxOutputTokens_FullBudget()
{
// Arrange & Act
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_000_000,
maxOutputTokens: 0);
// Assert — entire context window is the input budget
Assert.Equal(1_000_000, strategy.InputBudgetTokens);
}
}