Persist Conversations and Smart Memory Done
Learn to save conversation history across restarts and implement structured memory using AIContextProviders.
Overview
In the previous module, Jordan Miller gave the agent a “short-term” memory using sessions. However, that memory was volatile—it only lived as long as the application was running. If Jordan restarted the console app, the agent would lose all context of the ongoing incident.
In a real-world outage, investigations often span hours, multiple shifts, and handovers. This guide walks you through implementing Durable Persistence and Structured Memory. You’ll learn how to serialize an Agent Session to a file and use an AIContextProvider to extract specific facts (like the Incident ID) so that Jordan’s assistant remains smart across application restarts.
Agent Anatomy
We are now completing the “Memory” pillar by moving from volatile history to structured, persistable state.
Jordan’s on-call identity.
Reasoning about alerts.
External capabilities.
State and persistence.
Exposing as a service.
Solving the Context Window
In the previous module, we learned that the Context Window is finite. If you rely only on Session History, your token usage grows with every message until the window overflows. Smart Memory (via AIContextProvider) solves this. By extracting high-value facts (like the Incident ID) into a compact, structured format, you can provide the agent with essential context that never grows out of control, even in conversations spanning hundreds of turns.
Setup your environment
If you are continuing from the previous tutorial, you can use your existing project. Otherwise, follow the steps below to initialize a new one.
📋 Pre-flight Checklist
- 🛠️ .NET 10.0 SDK installed.
- 🤖 AI Provider: Azure OpenAI or Local (Ollama/LM Studio).
- 💾 Persistence: We will use
SerializeSessionAsyncto demonstrate state saving.
1 Install required packages
We are using the same core packages as the previous modules.
dotnet add package Microsoft.Agents.AI.OpenAI
dotnet add package OpenAI
dotnet add package Microsoft.Extensions.AI
dotnet restoreMicrosoft.Agents.AI.OpenAIThe core Agent Framework package. It provides the AsAIAgent extension and the base classes for AIContextProvider.
Microsoft.Extensions.AIProvides the unified .NET abstractions for AI. We use this to describe typed context objects for our memory providers.
dotnet add package Microsoft.Agents.AI.OpenAI
dotnet add package Azure.AI.OpenAI
dotnet add package Azure.Identity
dotnet add package Microsoft.Extensions.AI
dotnet restoreMicrosoft.Agents.AI.OpenAIThe core Agent Framework package. It provides the AsAIAgent extension and the base classes for AIContextProvider.
Microsoft.Extensions.AIProvides the unified .NET abstractions for AI. We use this to describe typed context objects for our memory providers.
Build the Agent
In this module, you’ll evolve your triage agent to track structured operator context—like names and active incident IDs—while implementing durable session persistence to ensure it never forgets a detail, even after a system restart.
Before we dive into the implementation, let’s look at the architectural flow that governs how your agent balances volatile chat history with persistent, structured memory:
graph TD
User(["User Input"]) --> SessionHistory["Session History<br/>(Volatile Transcript)"]
SessionHistory --> ContextWindow{"LLM Context Window"}
Provider["AIContextProvider"]
Store[("session.json")]
Provider <--> Store
ContextWindow -- "1. Extract Facts" --> Provider
Provider -- "2. Inject Instructions" --> ContextWindow
ContextWindow --> Assistant(["Agent Response"])
style Provider fill:#f9f9ff,stroke:#6366f1,stroke-width:2px
style Store fill:#f9f9ff,stroke:#6366f1,stroke-width:2px
style ContextWindow fill:#e0e7ff,stroke:#4338ca,stroke-width:2px,stroke-dasharray: 5 5The hybrid flow of volatile history and persistent structured state.
1 Define the Memory Provider 💾 Memory
The AIContextProvider is the framework’s hook for custom memory. We use ProviderSessionState<T> to manage the data lifecycle.
Replace Program.cs with the following code:
using OpenAI;
using OpenAI.Chat;
using Microsoft.Agents.AI;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
using System.ComponentModel;
using System.IO;
using System.ClientModel;
// 1. Configure the Provider
var endpoint = Environment.GetEnvironmentVariable("OPENAI_ENDPOINT") ?? "http://localhost:1234/v1";
var modelName = Environment.GetEnvironmentVariable("OPENAI_MODEL_NAME") ?? "google/gemma-4-e4b";
var chatClient = new OpenAIClient(new ApiKeyCredential("dummy"), new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(modelName);
// 2. Initialize Agent with Memory Provider and Tools
var operatorMemory = new OperatorMemory(chatClient.AsIChatClient());
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "TriageAgent",
ChatOptions = new()
{
Instructions = """
You are an enterprise incident triage assistant.
Summarize the incident, identify likely severity,
and suggest the next investigation step.
Always address the operator by their name and use their role to tailor your response.
Keep answers concise and operational.
""",
Tools = [
AIFunctionFactory.Create(GetServiceStatus, "GetServiceStatus"),
AIFunctionFactory.Create(GetOnCallEngineer, "GetOnCallEngineer")
]
},
AIContextProviders = [operatorMemory]
});
// 3. Start a Session and Provide Context
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine("--- Turn 1: Setting the Incident ---");
Console.WriteLine(await agent.RunAsync("I am triaging the checkout latency incident in West Europe (INC-1042). My name is Jordan and I am the Lead Responder.", session));
// 4. Persistence: Save to File (Survives Restarts)
Console.WriteLine("\n--- Saving Session to Disk ---");
string filePath = "session.json";
JsonElement sessionJson = await agent.SerializeSessionAsync(session);
await File.WriteAllTextAsync(filePath, sessionJson.GetRawText());
Console.WriteLine($"History saved to {filePath}.");
// 5. Rehydration: Load from File (Simulated Restart)
Console.WriteLine("\n--- Simulating App Restart ---");
string savedJson = await File.ReadAllTextAsync(filePath);
JsonElement sessionData = JsonSerializer.Deserialize<JsonElement>(savedJson);
var restoredSession = await agent.DeserializeSessionAsync(sessionData);
Console.WriteLine("--- Turn 2: Recovered Context ---");
Console.WriteLine(await agent.RunAsync("Remind me, which incident am I handling and what is my role?", restoredSession));
// --- Tool Definitions (from Module 2) ---
[Description("Gets the current health status for an enterprise service.")]
static string GetServiceStatus(
[Description("The service name to check, such as checkout, payments, or inventory.")] string serviceName)
{
return serviceName.ToLowerInvariant() switch
{
"checkout" => "Checkout is DEGRADED in West Europe. P95 latency is 4.8s. Payment retries are elevated.",
"payments" => "Payments is HEALTHY. No active regional alerts.",
"inventory" => "Inventory is HEALTHY. Last sync 2 minutes ago.",
_ => $"{serviceName} has no active status record in the demo store."
};
}
[Description("Gets the name of the engineer currently on-call.")]
static string GetOnCallEngineer(
[Description("The service name to check.")] string serviceName) => "Taylor Vance (@tvance)";
// --- Memory Provider Implementation ---
internal sealed class OperatorMemory(IChatClient chatClient) : AIContextProvider
{
private readonly ProviderSessionState<OperatorContext> _sessionState = new(_ => new OperatorContext(), nameof(OperatorMemory));
public override IReadOnlyList<string> StateKeys => [_sessionState.StateKey];
public OperatorContext GetContext(AgentSession session) => _sessionState.GetOrInitializeState(session);
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken ct)
{
var op = _sessionState.GetOrInitializeState(context.Session);
if ((op.Name is null || op.Role is null || op.IncidentId is null) && context.RequestMessages.Any(m => m.Role == ChatRole.User))
{
var result = await chatClient.GetResponseAsync<OperatorContext>(context.RequestMessages,
new ChatOptions { Instructions = "Extract operator name, role, and incident ID if present." }, cancellationToken: ct);
op.Name ??= result.Result.Name;
op.Role ??= result.Result.Role;
op.IncidentId ??= result.Result.IncidentId;
}
_sessionState.SaveState(context.Session, op);
}
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken ct)
{
var op = _sessionState.GetOrInitializeState(context.Session);
var instructions = new StringBuilder();
if (op.Name != null) instructions.Append($"The operator is {op.Name}, a {op.Role}. ");
if (op.IncidentId != null)
{
// PRIORITY: Injected facts should override conflicting conversation history
instructions.Append($"The current incident context is {op.IncidentId}. (Priority: Use this ID even if history mentions a different one). ");
}
return new ValueTask<AIContext>(new AIContext {
Instructions = instructions.Length > 0 ? instructions.ToString() : "Ask for the operator's name, role, and the incident ID they are triaging."
});
}
}
internal sealed class OperatorContext
{
public string? Name { get; set; }
public string? Role { get; set; }
public string? IncidentId { get; set; }
} using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using System.Text;
using System.Text.Json;
using OpenAI.Chat;
using Microsoft.Extensions.AI;
using System.ComponentModel;
using System.IO;
// 1. Configure the Provider
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")!;
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME")!;
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName);
// 2. Initialize Agent with Memory Provider and Tools
var operatorMemory = new OperatorMemory(chatClient.AsIChatClient());
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "TriageAgent",
ChatOptions = new()
{
Instructions = """
You are an enterprise incident triage assistant.
Summarize the incident, identify likely severity,
and suggest the next investigation step.
Always address the operator by their name and use their role to tailor your response.
Keep answers concise and operational.
""",
Tools = [
AIFunctionFactory.Create(GetServiceStatus, "GetServiceStatus"),
AIFunctionFactory.Create(GetOnCallEngineer, "GetOnCallEngineer")
]
},
AIContextProviders = [operatorMemory]
});
// 3. Start a Session and Provide Context
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine("--- Turn 1: Setting the Incident ---");
Console.WriteLine(await agent.RunAsync("I am triaging the checkout latency incident in West Europe (INC-1042). My name is Jordan and I am the Lead Responder.", session));
// 4. Persistence: Save to File (Survives Restarts)
Console.WriteLine("\n--- Saving Session to Disk ---");
string filePath = "session.json";
JsonElement sessionJson = await agent.SerializeSessionAsync(session);
await File.WriteAllTextAsync(filePath, sessionJson.GetRawText());
Console.WriteLine($"History saved to {filePath}.");
// 5. Rehydration: Load from File (Simulated Restart)
Console.WriteLine("\n--- Simulating App Restart ---");
string savedJson = await File.ReadAllTextAsync(filePath);
JsonElement sessionData = JsonSerializer.Deserialize<JsonElement>(savedJson);
var restoredSession = await agent.DeserializeSessionAsync(sessionData);
Console.WriteLine("--- Turn 2: Recovered Context ---");
Console.WriteLine(await agent.RunAsync("Remind me, which incident am I handling and what is my role?", restoredSession));
// --- Tool Definitions (from Module 2) ---
[Description("Gets the current health status for an enterprise service.")]
static string GetServiceStatus(
[Description("The service name to check, such as checkout, payments, or inventory.")] string serviceName)
{
return serviceName.ToLowerInvariant() switch
{
"checkout" => "Checkout is DEGRADED in West Europe. P95 latency is 4.8s. Payment retries are elevated.",
"payments" => "Payments is HEALTHY. No active regional alerts.",
"inventory" => "Inventory is HEALTHY. Last sync 2 minutes ago.",
_ => $"{serviceName} has no active status record in the demo store."
};
}
[Description("Gets the name of the engineer currently on-call.")]
static string GetOnCallEngineer(
[Description("The service name to check.")] string serviceName) => "Taylor Vance (@tvance)";
// --- Memory Provider Implementation ---
internal sealed class OperatorMemory(IChatClient chatClient) : AIContextProvider
{
private readonly ProviderSessionState<OperatorContext> _sessionState = new(_ => new OperatorContext(), nameof(OperatorMemory));
public override IReadOnlyList<string> StateKeys => [_sessionState.StateKey];
public OperatorContext GetContext(AgentSession session) => _sessionState.GetOrInitializeState(session);
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken ct)
{
var op = _sessionState.GetOrInitializeState(context.Session);
if ((op.Name is null || op.Role is null || op.IncidentId is null) && context.RequestMessages.Any(m => m.Role == ChatRole.User))
{
var result = await chatClient.GetResponseAsync<OperatorContext>(context.RequestMessages,
new ChatOptions { Instructions = "Extract operator name, role, and incident ID if present." }, cancellationToken: ct);
op.Name ??= result.Result.Name;
op.Role ??= result.Result.Role;
op.IncidentId ??= result.Result.IncidentId;
}
_sessionState.SaveState(context.Session, op);
}
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken ct)
{
var op = _sessionState.GetOrInitializeState(context.Session);
var instructions = new StringBuilder();
if (op.Name != null) instructions.Append($"The operator is {op.Name}, a {op.Role}. ");
if (op.IncidentId != null)
{
// PRIORITY: Injected facts should override conflicting conversation history
instructions.Append($"The current incident context is {op.IncidentId}. (Priority: Use this ID even if history mentions a different one). ");
}
return new ValueTask<AIContext>(new AIContext {
Instructions = instructions.Length > 0 ? instructions.ToString() : "Ask for the operator's name, role, and the incident ID they are triaging."
});
}
}
internal sealed class OperatorContext
{
public string? Name { get; set; }
public string? Role { get; set; }
public string? IncidentId { get; set; }
} Production State Architecture
Designing for Scale and Reliability
Sub-millisecond retrieval of active sessions. Perfect for agents requiring real-time responsiveness.
Durable, transactional storage for complete message logs, audit trails, and long-term history.
Cost-effective storage for large session snapshots, document artifacts, and offline backups.
The Agent Framework is storage-agnostic. As long as you can serialize the session state and restore it later, your agent can pick up exactly where it left off.
Try it
Experiment with how the Agent Framework handles structured state and persistence.
Simulate a Recovery
To prove persistence, we will simulate a “Fresh Run” where the agent has no memory in RAM, but loads it from disk.
To understand what’s happening under the hood:
graph TD
Session["Active Agent Session"] -- "SerializeSessionAsync" --> JSON["session.json (UTF-8)"]
JSON -- "File.WriteAllText" --> Disk[("Local Disk")]
Disk -- "File.ReadAllText" --> JSON_Reloaded["session.json"]
JSON_Reloaded -- "DeserializeSessionAsync" --> RestoredSession["Restored Agent Session"]
style Session fill:#e0e7ff,stroke:#4338ca
style RestoredSession fill:#e0e7ff,stroke:#4338ca
style Disk fill:#f9f9ff,stroke:#6366f1How state survives application restarts and crashes.
- Comment out your Turn 1 code (the introduction) in your
Program.cs. - Add these lines to the end of your file:
// Load the state from the previous run
string savedJson = await File.ReadAllTextAsync("session.json");
JsonElement sessionData = JsonSerializer.Deserialize<JsonElement>(savedJson);
var restoredSession = await agent.DeserializeSessionAsync(sessionData);
Console.WriteLine("\n--- Fresh Run: Testing Recovered Memory ---");
Console.WriteLine(await agent.RunAsync("Who am I and what incident am I working on?", restoredSession));The Goal: The agent will correctly identify you and the incident ID, even though this “fresh” run never heard you introduce yourself.
Direct State Access
Smart Memory isn’t a black box. Your application can read the structured state directly without calling the LLM. Add this to the end of your Program.cs:
var op = operatorMemory.GetContext(restoredSession);
Console.WriteLine($"\n[APP LOG]: Memory check -> Operator: {op?.Name}, Role: {op?.Role}");The Goal: Observe how the application “knows” who Jordan is because the OperatorMemory provider extracted that fact into a typed C# object.
Manipulate the Context
Because the state is stored in a standard C# object, your application can update it programmatically. Try overriding the incident ID:
var context = operatorMemory.GetContext(restoredSession);
context.IncidentId = "INC-9999 (PRIORITY OVERRIDE)";
Console.WriteLine("\n--- Turn 3: Manual Override ---");
Console.WriteLine(await agent.RunAsync("Which incident am I handling now?", restoredSession));The Goal: The agent will immediately recognize the new ID, proving that AIContextProvider can steer the agent even if the chat history contains conflicting information.
🧠 Pro-Tip: Resolving Fact Conflicts
If the agent sees a fact in the History (e.g., “I’m handling INC-1042”) that contradicts a fact in Smart Memory (e.g., “Active Incident: INC-9999”), it might get confused. By adding “Priority” or “Source of Truth” language to your provider’s instructions, you ensure your application code always has the final word.
Summary & Next Steps
Congratulations! You’ve graduated from basic chat history to a Durable Enterprise Agent.
By combining Session Serialization and AIContextProviders, you’ve built an agent that:
- Remembers across application restarts.
- Extracts structured facts into typed C# objects.
- Optimizes its own context window for better performance.
In the next tutorial, we will conclude this journey by taking Jordan’s assistant out of the terminal and hosting it as a scalable service.