Microsoft Agent Framework: Giving Agents Contextual Memory Using AIContextProvider
In earlier blog posts we created a personal trainer agent. It featured capabilities to process bookings search for and generate high protein recipes by using function tools and custom service classes.
We saw how to implement human develop within the context of a payment scenario.
In this blog post we look at how you can give agents access to memory across conversational threads.
The following is covered:
- why give your agent memory
- overview of AI context provider
- how to persist agent memory
- how to fetch agent memory
- creating a function tool to calculate weight loss time
- serialising agent memory
We also look at events you can subscribe to when working with agent memory. We see how this can be used to give an LLM additional context or to ensure the LLM has all relevant information before processing a prompt.
Code and a demo are included.
Let’s dig in.
~
Why Give Your Agent Memory
When people interact with your agent it can be helpful to persist certain aspects of the conversation.
This might be a person’s name, preferences or core contextual data that you don’t want them to repeat.
Giving you agents memory transforms it from a stateless interaction into a personalise assistant that knows what the person wants to achieve.
Memory can be serialised to a database and reloaded days later, thereby letting a person pickup where they left off -all without having to resupply data they have already shared. A positive user experience.
Whether you’re building a fitness tracker, project manager, or personal assistant, memory is what turns a simple Q&A system into a stateful system that understands and adapts to each user’s journey.
~
How It Works
To give your agent memory when working with the Microsoft Agent Framework, you do this by using the AIContextProvider class.
This lets you hook into the agent’s lifecycle and maintain state between invocations.
The pattern is straightforward. You can create custom context providers that remember information about users, persist preferences, or maintain any state your agent needs to function effectively.
The AIContextProvider class gives you two key events:
- InvokingAsync runs before each agent invocation. This is where you inject context into the request. You can provide additional instructions, tools, or messages that will be merged with the agent’s existing context. If your memory component knows the user’s current weight, you can include that information here so the agent can use it in calculations.
- InvokedAsync runs after the agent receives a response. This is where you inspect what happened and update your state. In a memory component, this is where you’d extract new information from the conversation to remember for next time.
Use these events to invoke logic before and after your agent invokes the inference service it’s connected to.
~
A Practical Example: Personal Trainer Agent
We can extend our Iron Mind AI personal trainer agent to remembers client details and can calculate weight loss timelines.
Here’s the memory component that tracks the persons name, current weight, and target weight:
public class ClientDetailsMemory : AIContextProvider
{
private readonly IChatClient _chatClient;
public ClientDetailsModels UserInfo { get; set; }
public ClientDetailsMemory(IChatClient chatClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
{
this._chatClient = chatClient;
this.UserInfo = serializedState.ValueKind == JsonValueKind.Object ?
serializedState.Deserialize<ClientDetailsModels>(jsonSerializerOptions)! :
new ClientDetailsModels();
}
public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
if ((this.UserInfo.CurrentWeight is null || this.UserInfo.DesiredWeight is null || this.UserInfo.Name is null)
&& context.RequestMessages.Any(x => x.Role == ChatRole.User))
{
Console.WriteLine();
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("[INVOKED] Attempting to extract missing user details...");
Console.WriteLine($"[BEFORE EXTRACTION] Name: '{UserInfo.Name}', Current: '{UserInfo.CurrentWeight}', Desired: '{UserInfo.DesiredWeight}'");
Console.ResetColor();
var result = await this._chatClient.GetResponseAsync<ClientDetailsModels>(
context.RequestMessages,
new ChatOptions()
{
Instructions = "Extract the user's name, current weight and desired weight from the conversation. " +
"Rules: " +
"1. The FIRST weight mentioned is the current weight. " +
"2. The SECOND weight mentioned is the desired weight. " +
"3. If only one weight is mentioned, it's the current weight. " +
"4. Return empty string or omit the property for values not found. DO NOT return the word 'null'."
},
cancellationToken: cancellationToken);
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"[EXTRACTION RESULT] Name: '{result.Result.Name}', Current: '{result.Result.CurrentWeight}', Desired: '{result.Result.DesiredWeight}'");
Console.ResetColor();
// Sanitize: convert "null" string or empty to actual null
this.UserInfo.CurrentWeight ??= SanitizeValue(result.Result.CurrentWeight);
this.UserInfo.DesiredWeight ??= SanitizeValue(result.Result.DesiredWeight);
this.UserInfo.Name ??= SanitizeValue(result.Result.Name);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[AFTER UPDATE] Name: '{UserInfo.Name}', Current: '{UserInfo.CurrentWeight}', Desired: '{UserInfo.DesiredWeight}'");
Console.ResetColor();
}
}
private static string? SanitizeValue(string? value)
{
if (string.IsNullOrWhiteSpace(value) ||
value.Equals("null", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return value.Trim();
}
public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
StringBuilder instructions = new();
instructions
.AppendLine(this.UserInfo.CurrentWeight is null ?
"Ask the user for their current weight and politely decline to answer any questions until they provide it." :
$"The user's current weight is {this.UserInfo.CurrentWeight}.")
.AppendLine(this.UserInfo.DesiredWeight is null ?
"Ask the user for their desired weight and politely decline to answer any questions until they provide it." :
$"The user's desired weight is {this.UserInfo.DesiredWeight}.")
.AppendLine(this.UserInfo.Name is null ?
"Ask the user for their name." :
$"The user's name is {this.UserInfo.Name}.");
return new ValueTask<AIContext>(new AIContext
{
Instructions = instructions.ToString()
});
}
//Serialization only happens when persistence is needed (e.g., saving to a database or file)
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
var serialized = JsonSerializer.SerializeToElement(this.UserInfo, jsonSerializerOptions);
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"[SERIALIZE CALLED] Returning: {serialized}");
Console.WriteLine($"[SERIALIZE] Name: '{UserInfo.Name}', Current: '{UserInfo.CurrentWeight}', Desired: '{UserInfo.DesiredWeight}'");
Console.ResetColor();
return serialized;
}
}
In the above code, several console logs are added to show the invocation of the memory event lifecycle during pre and post LLM inference calls.
The model class is simple:
public class ClientDetailsModels
{
public string Name { get; set; }
public string CurrentWeight { get; set; }
public string DesiredWeight { get; set; }
}
The component uses a separate LLM call to extract structured data from user messages. This structured output approach keeps your memory extraction reliable and type-safe.
~
Adding a Function Tool To Calculate Weight Loss Timeline
The agent also has a function tool that calculates weight loss timelines:
public class PersonalTrainerAgent
{
[Description("Calculates the time taken to reach target weight")]
public static DateTime GetTimeToReachWeight([Description("Persons current weight")] int currentKilogram, int targetKilograms)
{
const double weeklyWeightLossKg = 0.5;
if (currentKilogram <= targetKilograms)
{
throw new ArgumentException("Target weight must be less than current weight.");
}
int totalWeightToLose = currentKilogram - targetKilograms;
int weeksNeeded = (int)Math.Ceiling(totalWeightToLose / weeklyWeightLossKg);
return DateTime.Now.AddDays(weeksNeeded * 7);
}
}
The agent can call this function automatically when users ask about their weight loss timeline.
Our memory component ensures the agent always has access to the user’s current and target weights. If the user hasn’t already supplied these values, the memory component helps ensure the user supplies them.
~
Wiring It Up
Here’s how you create the agent with memory and tools:
ChatClient chatClient = new OpenAIClient(apiKey)
.GetChatClient(model);
AIAgent agent = chatClient.CreateAIAgent(new ChatClientAgentOptions()
{
Name = "IronMind AI",
ChatOptions = new()
{
Instructions = "You are a personal trainer. " +
"You can only answer questions related to health, fitness and 5x5 stronglift. " +
"Use local function tools",
Tools = [AIFunctionFactory.Create(PersonalTrainerAgent.GetTimeToReachWeight)]
},
AIContextProviderFactory = ctx => new ClientDetailsMemory(
chatClient.AsIChatClient(),
ctx.SerializedState,
ctx.JsonSerializerOptions),
});
The factory gets called every time a thread is created or deserialized. Each thread gets its own memory component instance.
~
Implementing the Chat Loop
Here’s a simple console loop that integrates everything:
private static async Task RunChatLoopWithThreadAsync(AIAgent agent)
{
AgentThread agentThread = agent.GetNewThread();
while (true)
{
Console.Write("You: ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
break;
if(input.Equals("save"))
{
// TEST: Explicitly serialize the thread
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("--- Testing Serialization ---");
var serializedThread = agentThread.Serialize();
Console.WriteLine($"Serialized thread: {serializedThread}");
Console.ResetColor();
Console.WriteLine();
}
UserChatMessage chatMessage = new(input);
AsyncCollectionResult<StreamingChatCompletionUpdate> completionUpdates =
agent.RunStreamingAsync([chatMessage], agentThread);
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("IronMind AI: ");
await foreach (StreamingChatCompletionUpdate completionUpdate in completionUpdates)
{
if (completionUpdate.ContentUpdate.Count > 0)
{
Console.Write(completionUpdate.ContentUpdate[0].Text);
}
}
Console.WriteLine();
Console.ResetColor();
Console.WriteLine();
// Access memory after streaming completes
var memory = agentThread.GetService<ClientDetailsMemory>();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("--- Agent Memory Contents ---");
Console.WriteLine($"Person Name: {memory?.UserInfo?.Name}");
Console.WriteLine($"Current Weight: {memory?.UserInfo?.CurrentWeight}");
Console.WriteLine($"Desired Weight: {memory?.UserInfo?.DesiredWeight}");
Console.WriteLine("-----------------------------");
Console.ResetColor();
Console.WriteLine();
}
}
You access the memory component via agentThread.GetService<ClientDetailsMemory>().
This lets you inspect or modify the memory state between turns.
~
Persistence
Memory is only useful if it persists. Implement the Serialize method and provide a constructor that accepts a JsonElement:
//Serialization only happens when persistence is needed (e.g., saving to a database or file)
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
var serialized = JsonSerializer.SerializeToElement(this.UserInfo, jsonSerializerOptions);
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"[SERIALIZE CALLED] Returning: {serialized}");
Console.WriteLine($"[SERIALIZE] Name: '{UserInfo.Name}', Current: '{UserInfo.CurrentWeight}', Desired: '{UserInfo.DesiredWeight}'");
Console.ResetColor();
return serialized;
}
Our earlier code that captures the input of `save` invokes the above code. i.e.
if(input.Equals("save"))
{
// Explicitly serialize the thread
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("--- Testing Serialization ---");
var serializedThread = agentThread.Serialize();
Console.WriteLine($"Serialized thread: {serializedThread}");
Console.ResetColor();
Console.WriteLine();
}
When you serialize a thread, the memory state goes with it. When you deserialize that thread later, your memory component comes back with all its state intact.
~
Demo
See a demo of this agent in action here:
Summary
The AIContextProvider pattern gives you a clean way to build stateful agents.
You get lifecycle hooks, serialisation support, and thread isolation out of the box.
Combined with function tools, you can create agents that remember context and take actions based on that context.
The personal trainer example shows how this works in practice.
The agent collects client details within a conversation, that information is used to guide the conversation, and can calculate weight loss timelines using function tools.
In the next blog post, we’ll see how you can use middleware to intercept events within the agentic lifecycle.
~
Enjoy what you’ve read, have questions about this content, or would like to see another topic? Drop me a note below.
You can schedule a call using my Calendly link to discuss consulting and development services.
~