Microsoft Agent Framework Converstation&Memory Created: 23 Feb 2026 Updated: 23 Feb 2026

Local Session State

Storage controls where conversation history lives, how much history is loaded, and how reliably sessions can be resumed. When a service does not require server-side persistence, Agent Framework keeps history locally inside AgentSession.state using an in-memory chat history provider. This lesson explores every built-in option and shows how to implement custom providers.

Built-in Storage Modes

ModeWhere history livesTypical usage
Local session stateFull chat history in AgentSession.state via InMemoryChatHistoryProviderServices that do not require server-side conversation persistence
Service-managed storageConversation state in the service; AgentSession.service_session_id points to itServices with native persistent conversation support

Demo 1 — In-Memory Chat History Storage

When no provider is specified, InMemoryChatHistoryProvider is used by default. History accumulates inside AgentSession.state and every message sent to the model on subsequent turns includes the full prior conversation.

After the conversation, the raw ChatMessage list can be retrieved from the provider for inspection or logging via provider.GetMessages(session).

Full Example

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;

public static class Demo1_InMemoryStorage
{
public static async Task RunAsync(string apiKey)
{
// Create the provider explicitly so we can inspect its contents after the conversation.
var historyProvider = new InMemoryChatHistoryProvider();

// Build the agent with the explicit provider.
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient("gpt-4o-mini")
.AsIChatClient()
.AsAIAgent(new ChatClientAgentOptions
{
Name = "Assistant",
ChatOptions = new() { Instructions = "You are a helpful assistant." },
ChatHistoryProvider = historyProvider
});

// Create a session — each session has its own isolated history.
AgentSession session = await agent.CreateSessionAsync();

// Turn 1 — introduce context the agent should remember.
Console.WriteLine("Turn 1 — User: My name is Alice. Remember it.");
AgentResponse r1 = await agent.RunAsync("My name is Alice. Remember it.", session);
Console.WriteLine($"Agent: {r1.Text}");

// Turn 2 — verify the agent recalls the context from turn 1.
Console.WriteLine("Turn 2 — User: What is my name?");
AgentResponse r2 = await agent.RunAsync("What is my name?", session);
Console.WriteLine($"Agent: {r2.Text}");

// Turn 3 — continue with another topic; history still accumulated.
Console.WriteLine("Turn 3 — User: Tell me a short pirate joke.");
AgentResponse r3 = await agent.RunAsync("Tell me a short pirate joke.", session);
Console.WriteLine($"Agent: {r3.Text}");

// Retrieve stored messages from the provider via GetMessages.
var messages = historyProvider.GetMessages(session);
Console.WriteLine($"Total messages stored in session: {messages.Count}");
foreach (var msg in messages)
{
string preview = msg.Text is { Length: > 0 }
? msg.Text[..int.Min(70, msg.Text.Length)]
: "(no text content)";
Console.WriteLine($" [{msg.Role.Value,10}] {preview}");
}
}
}

Key Points

  1. ChatClientAgentOptions.ChatHistoryProvider accepts the provider instance directly (not a factory).
  2. historyProvider.GetMessages(session) returns the List<ChatMessage> for a specific session.
  3. Each AgentSession maintains isolated history — multiple sessions do not interfere.

Key Types

TypeRole
AIAgentThe agent that orchestrates LLM calls and provider hooks
AgentSessionHolds per-conversation state including the message list
InMemoryChatHistoryProviderDefault provider; stores messages in AgentSession.state
ChatClientAgentOptionsOptions bag for agent name, chat options, and history provider

Demo 2 — Reducing In-Memory History Size

As a conversation grows, the accumulated history may exceed the model's context window. MessageCountingChatReducer(N) trims the oldest messages once the count exceeds N, keeping token usage bounded. The reducer is configured via InMemoryChatHistoryProviderOptions.

Full Example

#pragma warning disable MEAI001 // MessageCountingChatReducer is in preview

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;

public static class Demo2_HistoryReducer
{
public static async Task RunAsync(string apiKey)
{
const int maxMessages = 4; // keep only the last 4 messages in history

var reducer = new MessageCountingChatReducer(maxMessages);
var historyProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
ChatReducer = reducer,
ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded
});

AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient("gpt-4o-mini")
.AsIChatClient()
.AsAIAgent(new ChatClientAgentOptions
{
Name = "Assistant",
ChatOptions = new() { Instructions = "You are a helpful assistant." },
ChatHistoryProvider = historyProvider
});

AgentSession session = await agent.CreateSessionAsync();

// Run more turns than the cap so the reducer is forced to trim.
string[] turns =
[
"My favourite colour is blue.",
"My favourite animal is a fox.",
"My favourite food is pizza.",
"My favourite city is Tokyo.",
"What is my favourite colour?", // likely forgotten — trimmed by reducer
"What is my favourite city?", // should still be in the recent window
];

foreach (var turn in turns)
{
Console.WriteLine($"User : {turn}");
AgentResponse answer = await agent.RunAsync(turn, session);
Console.WriteLine($"Agent: {answer.Text}");
}

// Show what the reducer kept in memory.
var messages = historyProvider.GetMessages(session);
Console.WriteLine($"--- Messages retained in memory: {messages.Count} (cap was {maxMessages}) ---");
foreach (var msg in messages)
{
string preview = msg.Text is { Length: > 0 }
? msg.Text[..int.Min(80, msg.Text.Length)]
: "(no text content)";
Console.WriteLine($" [{msg.Role.Value,10}] {preview}");
}
}
}

Key Points

  1. The reducer is configured via InMemoryChatHistoryProviderOptions.ChatReducer, not as a constructor argument.
  2. ReducerTriggerEvent controls when trimming runs — AfterMessageAdded or BeforeMessagesRetrieval.
  3. The ChatReducerTriggerEvent enum lives inside InMemoryChatHistoryProviderOptions.
  4. After enough turns, early messages are dropped and the agent "forgets" them.

Key Types

TypeRole
InMemoryChatHistoryProviderOptionsConfiguration for the in-memory provider, including the reducer and trigger event
MessageCountingChatReducerTrims history to at most N messages
ChatReducerTriggerEventEnum controlling when the reducer fires (AfterMessageAdded / BeforeMessagesRetrieval)

Note: Reducer configuration applies only to in-memory history providers. For service-managed history, reduction behaviour is provider/service specific.

Demo 3 — Simple Custom ChatHistoryProvider

The base class ChatHistoryProvider exposes two virtual methods for the common load/store case:

  1. ProvideChatHistoryAsync — return stored history before each LLM call
  2. StoreChatHistoryAsync — persist new messages after each LLM call

The base class handles merging, source-stamping, error handling, and filtering automatically. Overriding only these two methods is the recommended starting point for custom providers.

Full Example

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;

public static class Demo3_SimpleCustomProvider
{
public static async Task RunAsync(string apiKey)
{
var historyProvider = new SimpleCustomChatHistoryProvider();

AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient("gpt-4o-mini")
.AsIChatClient()
.AsAIAgent(new ChatClientAgentOptions
{
Name = "Assistant",
ChatOptions = new() { Instructions = "You are a helpful assistant." },
ChatHistoryProvider = historyProvider
});

AgentSession session = await agent.CreateSessionAsync();

Console.WriteLine("Turn 1 — User: My favourite number is 42.");
AgentResponse r1 = await agent.RunAsync("My favourite number is 42.", session);
Console.WriteLine($"Agent: {r1.Text}");

Console.WriteLine("Turn 2 — User: What is my favourite number?");
AgentResponse r2 = await agent.RunAsync("What is my favourite number?", session);
Console.WriteLine($"Agent: {r2.Text}");

// Show all messages stored by the custom provider.
Console.WriteLine($"--- Messages in custom provider: {historyProvider.Messages.Count} ---");
foreach (var msg in historyProvider.Messages)
{
Console.WriteLine($" [{msg.Role}] {msg.Text}");
}
}
}

// ---------------------------------------------------------------------------
// Custom provider implementation
// ---------------------------------------------------------------------------

public sealed class SimpleCustomChatHistoryProvider : ChatHistoryProvider
{
private readonly List<ChatMessage> _messages = [];

/// Read-only access to stored messages for inspection.
public IReadOnlyList<ChatMessage> Messages => _messages;

/// Called before the LLM call.
/// Return all stored history — the base class merges them with
/// new request messages and stamps their source automatically.
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
InvokingContext context,
CancellationToken cancellationToken = default) =>
new(_messages.ToList());

/// Called after a successful LLM call.
/// Persist the new request and response messages.
/// Already-stored history messages are filtered out by the base class.
protected override ValueTask StoreChatHistoryAsync(
InvokedContext context,
CancellationToken cancellationToken = default)
{
_messages.AddRange(context.RequestMessages);
if (context.ResponseMessages is not null)
_messages.AddRange(context.ResponseMessages);

return default;
}
}

Key Points

  1. Override ProvideChatHistoryAsync to load history and StoreChatHistoryAsync to persist new messages.
  2. The base class automatically filters out already-stored history messages in StoreChatHistoryAsync — only truly new messages arrive.
  3. The custom provider is assigned via ChatClientAgentOptions.ChatHistoryProvider just like the built-in one.

Key Types

TypeRole
ChatHistoryProviderAbstract base class for all history providers
InvokingContextContext passed to pre-invocation hooks; contains the current session and request messages
InvokedContextContext passed to post-invocation hooks; contains request messages, response messages, and any exception

Demo 4 — Advanced Custom ChatHistoryProvider

For full control over the message pipeline, override the lower-level hooks instead:

  1. InvokingCoreAsync — runs before the LLM call. Build the final message list that reaches the model: stamp history messages with their source using WithAgentRequestMessageSource, then merge with the caller's input.
  2. InvokedCoreAsync — runs after the LLM call. Filter out history messages (identified via GetAgentRequestMessageSourceType) and persist only the newly generated request/response pairs.

Full Example

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;

public static class Demo4_AdvancedCustomProvider
{
public static async Task RunAsync(string apiKey)
{
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient("gpt-4o-mini")
.AsIChatClient()
.AsAIAgent(new ChatClientAgentOptions
{
Name = "Assistant",
ChatOptions = new() { Instructions = "You are a helpful assistant." },
ChatHistoryProvider = new AdvancedInMemoryChatHistoryProvider()
});

AgentSession session = await agent.CreateSessionAsync();

Console.WriteLine("Turn 1 — User: I live in Paris.");
AgentResponse r1 = await agent.RunAsync("I live in Paris.", session);
Console.WriteLine($"Agent: {r1.Text}");

Console.WriteLine("Turn 2 — User: What city do I live in?");
AgentResponse r2 = await agent.RunAsync("What city do I live in?", session);
Console.WriteLine($"Agent: {r2.Text}");

Console.WriteLine("Turn 3 — User: What is the capital of France?");
AgentResponse r3 = await agent.RunAsync("What is the capital of France?", session);
Console.WriteLine($"Agent: {r3.Text}");
}
}

// ---------------------------------------------------------------------------
// Advanced provider implementation
// ---------------------------------------------------------------------------

public sealed class AdvancedInMemoryChatHistoryProvider : ChatHistoryProvider
{
private readonly List<ChatMessage> _messages = [];

/// Prepend stored history to the caller's input messages before the LLM call.
/// Messages from history are stamped so they can be identified and excluded
/// from storage later (preventing duplication).
protected override ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(
InvokingContext context,
CancellationToken cancellationToken = default)
{
// Stamp each history message with its source so InvokedCoreAsync can filter it out.
var stamped = _messages.Select(m =>
m.WithAgentRequestMessageSource(
AgentRequestMessageSourceType.ChatHistory,
GetType().FullName!));

// Merge: history comes first, then the new caller input.
return new(stamped.Concat(context.RequestMessages));
}

/// After the LLM responds, persist only the new messages (exclude history
/// that was prepended above to avoid storing duplicates).
protected override ValueTask InvokedCoreAsync(
InvokedContext context,
CancellationToken cancellationToken = default)
{
if (context.InvokeException is not null)
return default;

// Filter out messages that originated from ChatHistory — they are already stored.
var newRequests = context.RequestMessages.Where(
m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory);

_messages.AddRange(newRequests.Concat(context.ResponseMessages ?? []));

return default;
}
}

Key Points

  1. WithAgentRequestMessageSource stamps a ChatMessage with its origin — ChatHistory, External, or AIContextProvider.
  2. GetAgentRequestMessageSourceType reads the stamp back so you can filter by origin in InvokedCoreAsync.
  3. This pattern prevents duplicate storage: history messages are re-sent to the model but not re-persisted.

Key Types

Type / MethodRole
AgentRequestMessageSourceTypeIdentifies message origin (ChatHistory, External, AIContextProvider)
WithAgentRequestMessageSourceExtension method to stamp a message with its source type and ID
GetAgentRequestMessageSourceTypeExtension method to read the source stamp from a message

Demo 5 — Persisting Sessions Across Restarts

An AgentSession can be serialised to a JsonElement and stored in any durable medium (file, database, blob storage). On restart, the snapshot is deserialised and the agent continues the conversation without any history loss.

Full Example

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.Text.Json;

public static class Demo5_PersistSession
{
public static async Task RunAsync(string apiKey)
{
// Build the agent (uses default InMemoryChatHistoryProvider).
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient("gpt-4o-mini")
.AsIChatClient()
.AsAIAgent(instructions: "You are a helpful assistant.", name: "Assistant");

// ── Phase 1: establish a conversation ──────────────────────────────
Console.WriteLine("--- Phase 1: starting conversation ---");
AgentSession session = await agent.CreateSessionAsync();

Console.WriteLine("User : Remember that the project name is 'Phoenix'.");
AgentResponse r1 = await agent.RunAsync("Remember that the project name is 'Phoenix'.", session);
Console.WriteLine($"Agent: {r1.Text}");

// Serialise the full session state to JSON.
JsonElement serialised = await agent.SerializeSessionAsync(session);
string json = serialised.GetRawText();

Console.WriteLine("Session serialised to JSON.");
Console.WriteLine($"Payload size: {json.Length} characters");

// Simulate storing the payload to durable storage and then reloading it.
// In a real application this would be a file write / database insert.
JsonElement reloaded = JsonDocument.Parse(json).RootElement;

// ── Phase 2: resume from the saved snapshot ────────────────────────
Console.WriteLine("--- Phase 2: resuming from serialised snapshot ---");
AgentSession resumed = await agent.DeserializeSessionAsync(reloaded);

Console.WriteLine("User : What is the project name?");
AgentResponse r2 = await agent.RunAsync("What is the project name?", resumed);
Console.WriteLine($"Agent: {r2.Text}");

Console.WriteLine("User : Also, what city is known as the City of Light?");
AgentResponse r3 = await agent.RunAsync("Also, what city is known as the City of Light?", resumed);
Console.WriteLine($"Agent: {r3.Text}");
}
}

Key Points

  1. SerializeSessionAsync returns a JsonElement snapshot of the full session state.
  2. DeserializeSessionAsync restores the session — the agent continues exactly where it left off.
  3. Always restore a session with the same agent and provider configuration that originally created it.
  4. Treat AgentSession as an opaque state object — do not manually modify its internals.

Key Methods

MethodDescription
agent.SerializeSessionAsync(session)Returns a JsonElement snapshot of the full session state
agent.DeserializeSessionAsync(element)Restores an AgentSession from a previously serialised snapshot

Summary

DemoTopicKey API
Demo 1Default in-memory storageInMemoryChatHistoryProvider, GetMessages(session)
Demo 2History size reductionMessageCountingChatReducer, InMemoryChatHistoryProviderOptions
Demo 3Simple custom providerProvideChatHistoryAsync, StoreChatHistoryAsync
Demo 4Advanced custom providerInvokingCoreAsync, InvokedCoreAsync, WithAgentRequestMessageSource
Demo 5Session persistenceSerializeSessionAsync, DeserializeSessionAsync

Required Packages

PackagePurpose
Microsoft.Agents.AIAIAgent, AgentSession, ChatHistoryProvider, InMemoryChatHistoryProvider, reducers
Microsoft.Extensions.AI.OpenAIAsIChatClient() extension and OpenAI integration

References

  1. Microsoft Agent Framework — Storage documentation


Share this lesson: