# Agent Framework chat history compaction in C# with Claude

> Measure what a growing chat history costs, compact it by turns, tokens or a summary, and see what the agent forgets.

Published: 27 Oct 2026
Series: Microsoft Agent Framework in C#, part 8
URL: https://kootstra.io/writing/chat-history-compaction-ai-agent-csharp/

**The idea:** a session resends every earlier turn, so each request is larger than the last until something removes messages, and whatever is removed, the model no longer knows.

The kitchen assistant from [the sessions post](https://kootstra.io/writing/agent-framework-sessions-csharp/) keeps a conversation by sending all of it to the model again on every turn. That makes each turn cost more input tokens than the one before. Microsoft Agent Framework does not trim the history by default. Its compaction API does: a `CompactionProvider` shortens what each request sends, using a strategy that keeps the last few turns, stays under a token budget, or replaces the older turns with a summary. The same six-turn conversation runs with each of those, after a first run with nothing to trim it, and then with the two chat reducers from `Microsoft.Extensions.AI`, which shorten the stored history instead. Each run prints the input tokens of every turn. The first fact in the conversation is "our son doesn't eat mushrooms", and the last question asks for it back.

**Versions:** Microsoft Agent Framework 1.24.0, .NET 10.

**Code:** [samples/agent-framework/08-bounded-history](https://github.com/nkootstra/samples/tree/main/agent-framework/08-bounded-history), a runnable version of this post with its tests.

**Who this is for:** C# developers who have kept a conversation in a session, as in [Remembering the conversation with agent sessions](https://kootstra.io/writing/agent-framework-sessions-csharp/).

> [!NOTE]
> The code runs on Claude Haiku 5.5, but it depends only on `IChatClient`: [the first post](https://kootstra.io/writing/microsoft-agent-framework-first-csharp-agent/#swap-the-provider-by-changing-one-statement) shows the swap to OpenAI or Ollama.
>
> The `SystemAsUserChatClient` wrapper exists because of the system and assistant messages Anthropic's API accepts in a summary request; with another provider, check whether the summarizers still need it.

## A scripted conversation on Claude Haiku

A console app with the `Microsoft.Agents.AI` and `Microsoft.Agents.AI.Anthropic` packages. One chat client for Claude Haiku 5.5, and instructions that keep the answers short. The output limit stays at 1024 tokens rather than the 4096 of [the structured output post](https://kootstra.io/writing/structured-output-ai-agent-csharp/), because the answers here are two sentences long: every answer below finishes with `stop`.

```csharp
using Anthropic;
using Anthropic.Core;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;

var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
    ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");

IChatClient chatClient =
    new AnthropicClient(new ClientOptions { ApiKey = apiKey })
        .AsIChatClient("claude-haiku-5-5", 1024);

const string Instructions =
    "You help one household decide what to cook. "
    + "Answer in at most two sentences.";
```

The conversation is a fixed script, so every configuration gets the same six questions. `RunScriptAsync` runs it on a new session and prints the input tokens and finish reason of every turn, then the last answer and what the session has stored:

```csharp
string[] script =
[
    "Our son doesn't eat mushrooms. "
        + "What can I cook tonight with rice, eggs and an onion?",
    "And tomorrow?",
    "I also have a leek. Does that change anything?",
    "Which of those keeps best for lunch the next day?",
    "Something quick for Friday?",
    "Remind me: what doesn't our son eat?",
];

async Task RunScriptAsync(AIAgent agent)
{
    AgentSession session = await agent.CreateSessionAsync();
    AgentResponse? response = null;

    foreach (string question in script)
    {
        response = await agent.RunAsync(question, session);
        Console.WriteLine(
            $"{response.Usage?.InputTokenCount,4} input tokens, "
            + $"{response.FinishReason}: {question}");
    }

    Console.WriteLine(response?.Text);

    if (session.TryGetInMemoryChatHistory(out List<ChatMessage>? history))
    {
        Console.WriteLine(
            $"{history.Count} messages stored, the first: {history[0].Text}");
    }
}
```

## Every turn costs more than the last

The first run uses an agent built like the one in [the sessions post](https://kootstra.io/writing/agent-framework-sessions-csharp/), with the default history provider and nothing to trim it:

```csharp
AIAgent agent = chatClient.AsAIAgent(
    name: "kitchen-assistant",
    instructions: Instructions);

await RunScriptAsync(agent);
```

```text
  66 input tokens, stop: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
 146 input tokens, stop: And tomorrow?
 243 input tokens, stop: I also have a leek. Does that change anything?
 339 input tokens, stop: Which of those keeps best for lunch the next day?
 537 input tokens, stop: Something quick for Friday?
 640 input tokens, stop: Remind me: what doesn't our son eat?
Your son doesn't eat mushrooms.
12 messages stored, the first: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
```

The questions are short, yet the sixth turn sends almost ten times the input of the first. Each request carries the instructions, every earlier question, every earlier answer, and the new question. In this run the six turns add up to 1,971 input tokens. The session holds all twelve messages, so the model can still answer the last question.

That growth has no ceiling. A conversation that goes on long enough costs more on every turn, and eventually it no longer fits in the model's context window.

## Keep the last turns with a sliding window

The compaction API lives in the `Microsoft.Agents.AI.Compaction` namespace. Its `CompactionProvider` is a context provider that runs at the start of each run. It groups the messages so that a tool call stays with its results, applies a compaction strategy, and sends only the messages that remain. The strategies cover a sliding window over turns, truncation, summarization, collapsing old tool results, and an existing `IChatReducer`, alone or several in a pipeline.

The provider goes into `AIContextProviders`. The `AsAIAgent` overload with `name` and `instructions` has no parameter for it, so the instructions move into `ChatOptions`, and the default history provider stays. A sliding window that allows three turns, the new question included:

```csharp
agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "kitchen-assistant",
    ChatOptions = new ChatOptions { Instructions = Instructions },
    AIContextProviders =
    [
        new CompactionProvider(new SlidingWindowCompactionStrategy(
            CompactionTriggers.TurnsExceed(3))),
    ],
});

await RunScriptAsync(agent);
```

A turn is a user message and everything after it up to the next one. The trigger decides when the strategy compacts, and by default it stops as soon as the trigger would no longer fire: here it drops the oldest turn whenever there are four. Its `minimumPreservedTurns` parameter, 1 by default, is a hard floor: the most recent turn stays even if the strategy hasn't reached its target yet. Besides `TurnsExceed`, `CompactionTriggers` has `TokensExceed`, `TokensBelow`, `MessagesExceed`, `GroupsExceed`, `HasToolCalls`, `Always` and `Never`, and `All` and `Any` to combine them.

The compaction API is marked experimental. Without a suppression the build stops with error MAAI001: "'Microsoft.Agents.AI.Compaction.CompactionProvider' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed." The chat reducers further down are experimental too, under MEAI001. Adding both IDs to `NoWarn` in the project file lets the build continue:

```xml
<NoWarn>$(NoWarn);MEAI001;MAAI001</NoWarn>
```

The same script, with the sliding window:

```text
  66 input tokens, stop: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
 169 input tokens, stop: And tomorrow?
 264 input tokens, stop: I also have a leek. Does that change anything?
 225 input tokens, stop: Which of those keeps best for lunch the next day?
 314 input tokens, stop: Something quick for Friday?
 498 input tokens, stop: Remind me: what doesn't our son eat?
I don't have any record of your son's food preferences or dislikes in our conversation. If you tell me what he avoids, I'll keep it in mind for future suggestions.
12 messages stored, the first: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
```

The first three turns still send the whole history. From the fourth turn on, every request carries the last two exchanges and the new question, and nothing older. The count still moves, because it now depends on how long the last two answers were, not on how long the conversation has been. This run used 1,536 input tokens over six turns.

The last answer is the cost. The mushrooms were in the first message, which left the request on the fourth turn. The model did not misremember them. The first message was not in the request, so it said it had no record, which is the right answer to the request it got.

### The session still holds everything

The last line of that run is the other half. The session holds all twelve messages, the first question included. `CompactionProvider` removes messages from the request, not from the history provider, which stores every question and answer as before. A session saved after this run, as in [the sessions post](https://kootstra.io/writing/agent-framework-sessions-csharp/), keeps the whole conversation, so the mushrooms are still in it although the model no longer sees them, and the stored list keeps growing while the requests don't.

The provider also keeps its own copy of every message group in the session's state, the excluded ones included, so that a run only has to add the new messages instead of grouping the whole history again. A saved session therefore holds the conversation twice: once in the history provider's state, and once in the compaction provider's. When the session lives on the service instead, with a `ConversationId`, the provider leaves the request alone.

## Stay under a token budget

A turn count says nothing about size: three turns with long answers or large tool results can still be too big. `ContextWindowCompactionStrategy` works from tokens. Its constructor takes the model's context window and its output limit, and the difference between them is the input budget. It then runs two strategies in a `PipelineCompactionStrategy`:

- `ToolResultCompactionStrategy`, above half the budget by default, replaces each older group of tool calls and their results with one short assistant message.
- `TruncationCompactionStrategy`, above 80% of the budget by default, drops the oldest message groups until the estimate is back under that line.

Both keep at least the two most recent groups. A real agent passes its model's numbers. To see the budget act within six short turns, this one pretends the window holds 1,324 tokens: with the output limit of 1,024, that leaves an input budget of 300, so tool results are collapsed above 150 tokens and truncation starts above 240:

```csharp
agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "kitchen-assistant",
    ChatOptions = new ChatOptions { Instructions = Instructions },
    AIContextProviders =
    [
        new CompactionProvider(new ContextWindowCompactionStrategy(
            maxContextWindowTokens: 1324,
            maxOutputTokens: 1024)),
    ],
});

await RunScriptAsync(agent);
```

```text
  66 input tokens, stop: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
 165 input tokens, stop: And tomorrow?
 264 input tokens, stop: I also have a leek. Does that change anything?
 380 input tokens, stop: Which of those keeps best for lunch the next day?
 259 input tokens, stop: Something quick for Friday?
 230 input tokens, stop: Remind me: what doesn't our son eat?
I don't have any record of your son's food preferences or dislikes, since this conversation hasn't mentioned them. If you tell me what he avoids, I'll keep it in mind for the rest of our planning.
12 messages stored, the first: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
```

The first four turns fit the budget and send the whole history. The fifth and sixth requests held two messages each: the previous answer and the new question. The cut is that deep because of how the strategy counts. `CompactionProvider` doesn't call a tokenizer: it estimates each group as its UTF-8 bytes divided by four, and that includes the data of a reasoning block. Claude Haiku 5.5 returned its fourth answer with one: the block had no visible text, but 1,256 characters of protected data that the next request sends back. With the 239 bytes of its text, that one answer counts as 373 estimated tokens, more than the whole budget, so truncation dropped every older group until only the two it must keep were left. The fifth answer had a reasoning block of its own, and the sixth request ended up the same way.

The six turns used 1,364 input tokens, fewer than any other run here, and the model knew the least. Two more details show in those requests. Truncation works on groups, not turns, so the fifth request started with an answer whose question was no longer in it, which Claude accepted. And the estimate covers only the messages: the instructions and tool definitions travel in `ChatOptions` and aren't counted, so a budget needs room for them.

The first phase had nothing to do here, because the conversation has no tool calls. With one, an older call to a `get_pantry` tool and its result would be sent as a single assistant message:

```text
[Tool Calls]
get_pantry:
  - rice, eggs
```

The two strategies also work alone, or in a pipeline of your own with `TokensExceed` triggers. `TruncationCompactionStrategy` keeps at least 32 groups by default and `ToolResultCompactionStrategy` 16, so on their own they need a lower floor before a short conversation changes at all.

## Summarize the older turns instead

`SummarizationCompactionStrategy` replaces the oldest groups with a summary that a chat client writes. The summary is a model call of its own, and its tokens don't appear in the agent's `Usage`, so the summarizer's client gets a step that prints them:

```csharp
async Task<ChatResponse> PrintSummaryUsageAsync(
    IEnumerable<ChatMessage> messages,
    ChatOptions? options,
    IChatClient inner,
    CancellationToken cancellationToken)
{
    ChatResponse summary = await inner.GetResponseAsync(
        messages, options, cancellationToken);
    Console.WriteLine(
        $"     summary: {summary.Usage?.InputTokenCount} input, "
        + $"{summary.Usage?.OutputTokenCount} output tokens, "
        + $"{summary.FinishReason}");
    return summary;
}

IChatClient summarizer = chatClient
    .AsBuilder()
    .Use(PrintSummaryUsageAsync, getStreamingResponseFunc: null)
    .Build();
```

The strategy takes the same trigger as the sliding window. It also keeps a minimum number of the most recent groups out of the summary, 8 by default, which in this conversation would hold back more than the trigger asks for, so it goes down to 2:

```csharp
new CompactionProvider(new SummarizationCompactionStrategy(
    summarizer,
    CompactionTriggers.TurnsExceed(3),
    minimumPreservedGroups: 2)),
```

### On Claude, a failed summary is silent

With that summarizer, the run finishes, and the answer is right:

```text
  66 input tokens, stop: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
 150 input tokens, stop: And tomorrow?
 242 input tokens, stop: I also have a leek. Does that change anything?
 344 input tokens, stop: Which of those keeps best for lunch the next day?
 454 input tokens, stop: Something quick for Friday?
 682 input tokens, stop: Remind me: what doesn't our son eat?
Your son doesn't eat mushrooms.
12 messages stored, the first: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
```

But no summary line appears, and the token counts grow as they did without compaction. Each of the last three turns did ask for a summary, and Claude rejected every request:

```text
Anthropic.Exceptions.AnthropicBadRequestException: Status Code: BadRequest
{"type":"error","error":{"type":"invalid_request_error","message":"This model does not support assistant message prefill. The conversation must end with a user message."},"request_id":"req_011Cg5ZZU6eXehNZxaKEfqd3"}
```

The strategy sends its prompt as a system message first, followed by the messages to summarize. Those end with the assistant's answer, which makes the request a prefill: it asks the model to continue that answer, and Claude Haiku 5.5 doesn't support that. The strategy catches the exception, puts the groups back, logs a warning, and the run continues with the whole history. With no logger configured, nothing shows it. `CompactionProvider` takes an optional `ILoggerFactory`, or uses the one the agent's chat client provides.

The fix is to give the strategy a chat client that moves the prompt to the end, as a user message. A `DelegatingChatClient` passes every call on to the client it wraps, and this one rearranges the messages on the way:

```csharp
using Microsoft.Extensions.AI;

// Sends system messages as user messages at the end of the request.
// Wraps the summarizers' client only.
public sealed class SystemAsUserChatClient(IChatClient innerClient)
    : DelegatingChatClient(innerClient)
{
    public override Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default) =>
        base.GetResponseAsync(AsUser(messages), options, cancellationToken);

    public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default) =>
        base.GetStreamingResponseAsync(
            AsUser(messages), options, cancellationToken);

    static List<ChatMessage> AsUser(IEnumerable<ChatMessage> messages) =>
    [
        .. messages.Where(message => message.Role != ChatRole.System),
        .. messages
            .Where(message => message.Role == ChatRole.System)
            .Select(message => new ChatMessage(ChatRole.User, message.Contents)),
    ];
}
```

It wraps only the client the summaries use. The agent keeps the plain chat client:

```csharp
agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "kitchen-assistant",
    ChatOptions = new ChatOptions { Instructions = Instructions },
    AIContextProviders =
    [
        new CompactionProvider(new SummarizationCompactionStrategy(
            new SystemAsUserChatClient(summarizer), // [!code highlight]
            CompactionTriggers.TurnsExceed(3),
            minimumPreservedGroups: 2)),
    ],
});

await RunScriptAsync(agent);
```

```text
  66 input tokens, stop: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
 167 input tokens, stop: And tomorrow?
 276 input tokens, stop: I also have a leek. Does that change anything?
     summary: 233 input, 238 output tokens, stop
 388 input tokens, stop: Which of those keeps best for lunch the next day?
     summary: 369 input, 205 output tokens, stop
 424 input tokens, stop: Something quick for Friday?
     summary: 412 input, 272 output tokens, stop
 538 input tokens, stop: Remind me: what doesn't our son eat?
Your son doesn't eat mushrooms.
12 messages stored, the first: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
```

From the fourth turn on, each request starts with a summary in the assistant's role, followed by the last two exchanges and the new question. Each summary starts from the previous one, and the mushrooms survived all three, so the answer is right. The session still holds the twelve original messages and no summary. The provider marks the summary as a message from the chat history, and the history provider skips those when it stores a run, so the summary lives only in the compaction provider's state.

It did not save tokens here. The agent's six turns used 1,859 input tokens, close to the 1,971 without compaction: the summaries were written as lists of every dish and step, about as long as the turns they replaced. The three summary calls added 1,014 input and 715 output tokens on top. A summary pays off in a conversation long enough that a few sentences replace many turns, and the summary text still decides what the model knows: a detail the summarizer leaves out is lost just as surely as a dropped message. A fact that must hold for the whole conversation, like the mushrooms, doesn't have to live in the history at all: a context provider can add it to every run, as [the post on the current date](https://kootstra.io/writing/ai-agent-current-date-csharp/) does with the date.

The summary is also model output that becomes part of the conversation. The strategy carries a security note in its documentation: a compromised summarizer can return text with injected instructions, and those stay in the conversation from then on.

## Shorten the stored history with a chat reducer

`Microsoft.Extensions.AI` has an older mechanism for the same problem. A chat reducer is an `IChatReducer`: it takes a list of messages and returns a shorter one. It goes into the options of an `InMemoryChatHistoryProvider`, and that provider goes into the agent options as `ChatHistoryProvider`, which replaces the default provider the agent would otherwise create. `MessageCountingChatReducer` keeps the most recent messages up to a count, plus the first system message if there is one:

```csharp
agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "kitchen-assistant",
    ChatOptions = new ChatOptions { Instructions = Instructions },
    ChatHistoryProvider = new InMemoryChatHistoryProvider(new()
    {
        ChatReducer = new MessageCountingChatReducer(4), // [!code highlight]
    }),
});

await RunScriptAsync(agent);
```

```text
  66 input tokens, stop: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
 144 input tokens, stop: And tomorrow?
 225 input tokens, stop: I also have a leek. Does that change anything?
 251 input tokens, stop: Which of those keeps best for lunch the next day?
 415 input tokens, stop: Something quick for Friday?
 470 input tokens, stop: Remind me: what doesn't our son eat?
I don't have any information about your son's food preferences or dislikes, so you'll need to tell me what he won't eat. If you share that, I can keep it in mind for future meal suggestions.
6 messages stored, the first: Which of those keeps best for lunch the next day?
```

The requests hold the same messages as the sliding window's: from the fourth turn on, the last four stored messages and the new question. This run used 1,571 input tokens, and the mushrooms were gone again.

The last line is the difference. The session holds six messages, and the first is the fourth question. `InMemoryChatHistoryProvider` replaces its stored list with whatever the reducer returns, so the first three turns are gone from the session, not only from the request, and a saved session contains only those six messages. Nothing keeps the dropped ones unless your own code does.

Six, not four, because of when the reducer runs. `InMemoryChatHistoryProviderOptions.ReducerTriggerEvent` has two values:

- `BeforeMessagesRetrieval`, the default, runs the reducer at the start of a run, when the provider reads the history for the request. The stored list is cut to four, and once the answer arrives the provider stores the question and the answer after it. Once the history has reached the limit, a turn leaves the session holding the limit plus two, or more when a tool call adds messages.
- `AfterMessageAdded` runs the reducer at the end of a run, after the question and the answer have been stored. The session then holds at most the limit, and the run finishes only after the reducer has.

The model sees the same messages either way. The difference is what a saved session contains, and when the work happens: with a reducer that calls a model, it adds that call to the start or the end of every run that reduces.

### A summarizing reducer

`SummarizingChatReducer` keeps the most recent messages as they are and replaces the older ones with a summary that a chat client writes. The constructor takes that client, a target count and a threshold. With a target of 4 and a threshold of 2, it waits until there are more than six messages, then summarizes everything before the last four or so. It moves the cut back to just before a user message within the threshold, so a question isn't separated from its answer.

It fails on Claude too, but differently. Passing the plain chat client:

```csharp
ChatHistoryProvider = new InMemoryChatHistoryProvider(new()
{
    ChatReducer = new SummarizingChatReducer(chatClient, 4, 2),
}),
```

```text
Unhandled exception. Anthropic.Exceptions.AnthropicBadRequestException: Status Code: BadRequest
{"type":"error","error":{"type":"invalid_request_error","message":"messages.4: role 'system' must follow a 'user' message or an 'assistant' message ending in a server tool result; the directive-only form (content: [] with output_config) is accepted at any position"},"request_id":"req_011Cg2XZKJ4pJ9spYahscr2G"}
```

This reducer sends its prompt last, as a system message after the messages to summarize. The Anthropic chat client sends a system message that isn't at the start of the list as a `system` message in that position. Claude Haiku 5.5 accepts [system messages in the middle of a conversation](https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages), but only after a user message, not after a plain assistant answer. So the request is rejected at the start of the fifth turn, the first time the reducer summarizes, and unlike the compaction strategy, the reducer lets the exception through. The same `SystemAsUserChatClient` fixes it: the prompt is already last, and it goes as a user message instead.

```csharp
agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "kitchen-assistant",
    ChatOptions = new ChatOptions { Instructions = Instructions },
    ChatHistoryProvider = new InMemoryChatHistoryProvider(new()
    {
        ChatReducer = new SummarizingChatReducer( // [!code highlight]
            new SystemAsUserChatClient(summarizer), 4, 2), // [!code highlight]
    }),
});

await RunScriptAsync(agent);
```

The output, with the stored summary cut short:

```text
  66 input tokens, stop: Our son doesn't eat mushrooms. What can I cook tonight with rice, eggs and an onion?
 159 input tokens, stop: And tomorrow?
 278 input tokens, stop: I also have a leek. Does that change anything?
 388 input tokens, stop: Which of those keeps best for lunch the next day?
     summary: 429 input, 183 output tokens, stop
 469 input tokens, stop: Something quick for Friday?
     summary: 508 input, 234 output tokens, stop
 547 input tokens, stop: Remind me: what doesn't our son eat?
Your son doesn't eat mushrooms.
7 messages stored, the first: The user mentioned their son doesn't eat mushrooms and has rice, eggs, and an onion on hand. [...]
```

The answer is right, and here the summary is in the stored history: the first of seven messages, in the assistant's role. The first summary ran at the start of the fifth turn, when the history had grown to eight messages, and the sixth turn summarized again, starting from the first summary. The cost looks like the summarization strategy's: 1,907 input tokens over six turns, plus 937 input and 417 output tokens for the two summaries.

### Tool calls and results

The model's request for a tool and the tool's result are separate messages, and a cut between them leaves one without the other. The compaction strategies never make that cut, because they work on groups, and a tool call is one group with its results. The two reducers handle it differently:

- `MessageCountingChatReducer` never splits a pair, because it drops every message that contains a function call or a function result, also from the most recent turn, together with any text the assistant wrote in the same message. Only the user's question and the assistant's final text answer survive. The next turn no longer sees which tool ran or what it returned.
- `SummarizingChatReducer` keeps them together. When its cut would land between a call and its result, it moves the cut back until the pair is on the kept side. It also leaves tool messages out of what it sends to the summarizer, so a tool result on the summarized side does not reach the summary.

## Which one to use

The five configurations differ in what they send and what they keep. The numbers are from the runs above, against 1,971 input tokens without any of them:

| Approach               | What the request keeps           | Stored history                     | Extra calls        | Input tokens, six turns         |
| ---------------------- | -------------------------------- | ---------------------------------- | ------------------ | ------------------------------- |
| Sliding window         | The last turns                   | Whole, plus a copy                 | None               | 1,536                           |
| Context window         | What fits an estimated budget    | Whole, plus a copy                 | None               | 1,364                           |
| Summarization strategy | A summary and the last turns     | Whole, plus a copy and the summary | One per compaction | 1,859, plus 1,014 for summaries |
| Counting reducer       | The last messages, no tool calls | Cut to match                       | None               | 1,571                           |
| Summarizing reducer    | A summary and the last messages  | Rewritten with the summary         | One per reduction  | 1,907, plus 937 for summaries   |

A sliding window is the most predictable: it cuts on turn boundaries, and the saved session keeps everything for later. A token budget protects the context window when single answers or tool results can be large, but it counts estimates, so leave room. Summaries keep facts from the older turns at the price of a model call each time, and pay off in long conversations. The reducers fit when the stored history itself has to stay small, because they are the only ones here that shorten it.

## Try it

- Pass `summarizer` to `SummarizationCompactionStrategy` without the `SystemAsUserChatClient` wrapper. The run looks like one without compaction, and no summary line appears.
- Set `ReducerTriggerEvent` to `InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded` on the counting reducer and run again. The token counts follow the same pattern, and the session now holds four messages instead of six.
- Raise the counting reducer to 10 and run again. In six turns the history never holds more than ten messages when the reducer runs, so it drops nothing and the last answer names the mushrooms again.
