Back to homepage

Niels Kootstra

Agent Framework chat history compaction in C# with Claude

Summarize with AI

The assistants open a new chat that asks for a short summary of this post.

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 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, 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.

Note

The code runs on Claude Haiku 5.5, but it depends only on IChatClient: the first post 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, because the answers here are two sentences long: every answer below finishes with stop.

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:

Every turn costs more than the last

The first run uses an agent built like the one in the sessions post, with the default history provider and nothing to trim it:

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:

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:

The same script, with the sliding window:

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, 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:

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:

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:

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:

On Claude, a failed summary is silent

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

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:

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:

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

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 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:

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:

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, 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.

The output, with the stored summary cut short:

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.