diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
index 9ed9f5bb04..c47ed3cf25 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
@@ -19,7 +19,9 @@ public static class HarnessConsole
/// The agent to interact with.
/// The title displayed in the console header.
/// A short prompt to the user, displayed below the title.
- public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt)
+ /// Optional max context window size in tokens. When set, usage is displayed as a percentage.
+ /// Optional max output tokens. Used with to show input/output budget breakdown.
+ public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, int? maxContextWindowTokens = null, int? maxOutputTokens = null)
{
var todoProvider = agent.GetService();
var modeProvider = agent.GetService();
@@ -46,7 +48,7 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP
}
else
{
- await StreamAgentResponseAsync(agent, session, modeProvider, userInput);
+ await StreamAgentResponseAsync(agent, session, modeProvider, userInput, maxContextWindowTokens, maxOutputTokens);
}
WritePrompt(modeProvider, session);
@@ -57,7 +59,7 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP
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 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s
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 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s
{
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 @@ private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSess
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 @@ private static void PrintTodos(TodoProvider? todoProvider, AgentSession session)
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,
diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj
index da636bc25a..b28ff5bf42 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj
+++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj
@@ -13,7 +13,7 @@
-
+
diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs
index c0a03448b9..fdd4d8abd1 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs
@@ -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 @@ This rule applies even when the answer seems obvious or the task seems small.
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 @@ This rule applies even when the answer seems obvious or the task seems small.
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);
diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md
index 6db0718c0f..270acf409b 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md
+++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md
@@ -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"
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ContextWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ContextWindowCompactionStrategy.cs
new file mode 100644
index 0000000000..5e177a372d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ContextWindowCompactionStrategy.cs
@@ -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;
+
+///
+/// A compaction strategy that derives token thresholds from a model's context window size
+/// and maximum output tokens, applying a two-phase compaction pipeline:
+///
+/// - Tool result eviction () — collapses old tool call groups
+/// into concise summaries when the token count exceeds the .
+/// - Truncation () — removes the oldest non-system message groups
+/// when the token count exceeds the .
+///
+///
+///
+///
+/// The input budget is defined as maxContextWindowTokens - maxOutputTokens, representing
+/// the maximum number of tokens available for the conversation input (including system messages, tools, and history).
+///
+///
+/// This strategy is a convenience wrapper around that automates
+/// threshold calculation from model specifications.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class ContextWindowCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// The default fraction of the input budget at which tool result eviction triggers.
+ ///
+ public const double DefaultToolEvictionThreshold = 0.5;
+
+ ///
+ /// The default fraction of the input budget at which truncation triggers.
+ ///
+ public const double DefaultTruncationThreshold = 0.8;
+
+ private readonly PipelineCompactionStrategy _pipeline;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
+ ///
+ ///
+ /// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
+ ///
+ ///
+ /// The fraction of the input budget (0.0, 1.0] at which tool result eviction triggers.
+ /// Defaults to (0.5).
+ ///
+ ///
+ /// The fraction of the input budget (0.0, 1.0] at which truncation triggers.
+ /// Defaults to (0.8).
+ /// Must be greater than or equal to .
+ ///
+ ///
+ /// is not positive, or
+ /// is negative or greater than or equal to , or
+ /// or is not in (0.0, 1.0], or
+ /// is less than .
+ ///
+ 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));
+ }
+
+ ///
+ /// Gets the maximum context window size in tokens.
+ ///
+ public int MaxContextWindowTokens { get; }
+
+ ///
+ /// Gets the maximum output tokens per response.
+ ///
+ public int MaxOutputTokens { get; }
+
+ ///
+ /// Gets the computed input budget in tokens ( minus ).
+ ///
+ public int InputBudgetTokens { get; }
+
+ ///
+ /// Gets the fraction of the input budget at which tool result eviction triggers.
+ ///
+ public double ToolEvictionThreshold { get; }
+
+ ///
+ /// Gets the fraction of the input budget at which truncation triggers.
+ ///
+ public double TruncationThreshold { get; }
+
+ ///
+ protected override async ValueTask 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].");
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ContextWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ContextWindowCompactionStrategyTests.cs
new file mode 100644
index 0000000000..af74110b6a
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ContextWindowCompactionStrategyTests.cs
@@ -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;
+
+///
+/// Contains tests for the class.
+///
+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(() =>
+ 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(() =>
+ 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(() =>
+ 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(() =>
+ new ContextWindowCompactionStrategy(1000, 100, truncationThreshold: threshold));
+ }
+
+ [Fact]
+ public void Constructor_TruncationBelowToolEviction_Throws()
+ {
+ // Act & Assert
+ Assert.Throws(() =>
+ 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 { ["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);
+ }
+}