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); + } +}