|
| 1 | +--- |
| 2 | +title: Adding Memory to an Agent |
| 3 | +description: How to add memory to an agent using an AIContextProvider. |
| 4 | +zone_pivot_groups: programming-languages |
| 5 | +author: westey-m |
| 6 | +ms.topic: tutorial |
| 7 | +ms.author: westey |
| 8 | +ms.date: 09/25/2025 |
| 9 | +ms.service: semantic-kernel |
| 10 | +--- |
| 11 | + |
| 12 | +# Adding Memory to an Agent |
| 13 | + |
| 14 | +This tutorial shows how to add memory to an agent by implementing an `AIContextProvider` and attaching it to the agent. |
| 15 | + |
| 16 | +> [!IMPORTANT] |
| 17 | +> Not all agent types support `AIContextProvider`. In this step we are using a `ChatClientAgent`, which does support `AIContextProvider`. |
| 18 | +
|
| 19 | +::: zone pivot="programming-language-csharp" |
| 20 | + |
| 21 | +## Prerequisites |
| 22 | + |
| 23 | +For prerequisites and installing nuget packages, see the [Create and run a simple agent](./run-agent.md) step in this tutorial. |
| 24 | + |
| 25 | +## Creating an AIContextProvider |
| 26 | + |
| 27 | +`AIContextProvider` is an abstract class that you can inherit from, and which can be associated with the `AgentThread` for a `ChatClientAgent`. |
| 28 | +It allows you to: |
| 29 | + |
| 30 | +1. run custom logic before and after the agent invokes the underlying inference service |
| 31 | +1. provide additional context to the agent before it invokes the underlying inference service |
| 32 | +1. inspect all messages provided to and produced by the agent |
| 33 | + |
| 34 | +### Pre and post invocation events |
| 35 | + |
| 36 | +The `AIContextProvider` class has two methods that you can override to run custom logic before and after the agent invokes the underlying inference service: |
| 37 | + |
| 38 | +- `InvokingAsync` - called before the agent invokes the underlying inference service. You can provide additional context to the agent by returning an `AIContext` object. This context will be merged with the agent's existing context before invoking the underlying service. It is possible to provide instructions, tools, and messages to add to the request. |
| 39 | +- `InvokedAsync` - called after the agent has received a response from the underlying inference service. You can inspect the request and response messages, and update the state of the context provider. |
| 40 | + |
| 41 | +### Serialization |
| 42 | + |
| 43 | +`AIContextProvider` instances are created and attached to an `AgentThread` when the thread is created, and when a thread is resumed from a serialized state. |
| 44 | + |
| 45 | +The `AIContextProvider` instance may have its own state that needs to be persisted between invocations of the agent. E.g. a memory component that remembers information about the user may have memories as part of its state. |
| 46 | + |
| 47 | +To allow persisting threads, you need to implement the `SerializeAsync` method of the `AIContextProvider` class. You also need to provide a constructor that takes a `JsonElement` parameter, which can be used to deserialize the state when resuming a thread. |
| 48 | + |
| 49 | +### Sample AIContextProvider implementation |
| 50 | + |
| 51 | +Let's look at an example of a custom memory component that remembers a user's name and age, and provides it to the agent before each invocation. |
| 52 | + |
| 53 | +First we'll create a model class to hold the memories. |
| 54 | + |
| 55 | +```csharp |
| 56 | +internal sealed class UserInfo |
| 57 | +{ |
| 58 | + public string? UserName { get; set; } |
| 59 | + public int? UserAge { get; set; } |
| 60 | +} |
| 61 | +``` |
| 62 | + |
| 63 | +Then we can implement the `AIContextProvider` to manage the memories. |
| 64 | +The `UserInfoMemory` class below contains the following behavior: |
| 65 | + |
| 66 | +1. It uses a `IChatClient` to look for the user's name and age in user messages when new messages are added to the thread at the end of each run. |
| 67 | +1. It provides any current memories to the agent before each invocation. |
| 68 | +1. If not memories are available, it instructs the agent to ask the user for the missing information, and not to answer any questions until the information is provided. |
| 69 | +1. It also implements serialization to allow persisting the memories as part of the thread state. |
| 70 | + |
| 71 | +```csharp |
| 72 | +internal sealed class UserInfoMemory : AIContextProvider |
| 73 | +{ |
| 74 | + private readonly IChatClient _chatClient; |
| 75 | + public UserInfoMemory(IChatClient chatClient, UserInfo? userInfo = null) |
| 76 | + { |
| 77 | + this._chatClient = chatClient; |
| 78 | + this.UserInfo = userInfo ?? new UserInfo(); |
| 79 | + } |
| 80 | + |
| 81 | + public UserInfoMemory(IChatClient chatClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null) |
| 82 | + { |
| 83 | + this._chatClient = chatClient; |
| 84 | + this.UserInfo = serializedState.ValueKind == JsonValueKind.Object ? |
| 85 | + serializedState.Deserialize<UserInfo>(jsonSerializerOptions)! : |
| 86 | + new UserInfo(); |
| 87 | + } |
| 88 | + |
| 89 | + public UserInfo UserInfo { get; set; } |
| 90 | + |
| 91 | + public override async ValueTask InvokedAsync( |
| 92 | + InvokedContext context, |
| 93 | + CancellationToken cancellationToken = default) |
| 94 | + { |
| 95 | + if ((this.UserInfo.UserName is null || this.UserInfo.UserAge is null) && context.RequestMessages.Any(x => x.Role == ChatRole.User)) |
| 96 | + { |
| 97 | + var result = await this._chatClient.GetResponseAsync<UserInfo>( |
| 98 | + context.RequestMessages, |
| 99 | + new ChatOptions() |
| 100 | + { |
| 101 | + Instructions = "Extract the user's name and age from the message if present. If not present return nulls." |
| 102 | + }, |
| 103 | + cancellationToken: cancellationToken); |
| 104 | + this.UserInfo.UserName ??= result.Result.UserName; |
| 105 | + this.UserInfo.UserAge ??= result.Result.UserAge; |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + public override ValueTask<AIContext> InvokingAsync( |
| 110 | + InvokingContext context, |
| 111 | + CancellationToken cancellationToken = default) |
| 112 | + { |
| 113 | + StringBuilder instructions = new(); |
| 114 | + instructions |
| 115 | + .AppendLine( |
| 116 | + this.UserInfo.UserName is null ? |
| 117 | + "Ask the user for their name and politely decline to answer any questions until they provide it." : |
| 118 | + $"The user's name is {this.UserInfo.UserName}.") |
| 119 | + .AppendLine( |
| 120 | + this.UserInfo.UserAge is null ? |
| 121 | + "Ask the user for their age and politely decline to answer any questions until they provide it." : |
| 122 | + $"The user's age is {this.UserInfo.UserAge}."); |
| 123 | + return new ValueTask<AIContext>(new AIContext |
| 124 | + { |
| 125 | + Instructions = instructions.ToString() |
| 126 | + }); |
| 127 | + } |
| 128 | + |
| 129 | + public override ValueTask<JsonElement?> SerializeAsync( |
| 130 | + JsonSerializerOptions? jsonSerializerOptions = null, |
| 131 | + CancellationToken cancellationToken = default) |
| 132 | + { |
| 133 | + return new ValueTask<JsonElement?>(JsonSerializer.SerializeToElement(this.UserInfo, jsonSerializerOptions)); |
| 134 | + } |
| 135 | +} |
| 136 | +``` |
| 137 | + |
| 138 | +## Using the AIContextProvider with an agent |
| 139 | + |
| 140 | +To use the custom `AIContextProvider`, you need to provide an `AIContextProviderFactory` when creating the agent. This factory allows the agent to create a new instance of the desired `AIContextProvider` for each thread. |
| 141 | + |
| 142 | +When creating a `ChatClientAgent` it is possible to provide a `ChatClientAgentOptions` object that allows providing the `AIContextProviderFactory` in addition to all other agent options. |
| 143 | + |
| 144 | +```csharp |
| 145 | +ChatClient chatClient = new AzureOpenAIClient( |
| 146 | + new Uri("https://<myresource>.openai.azure.com"), |
| 147 | + new AzureCliCredential()) |
| 148 | + .GetChatClient("gpt-4o-mini"); |
| 149 | + |
| 150 | +AIAgent agent = chatClient.CreateAIAgent(new ChatClientAgentOptions() |
| 151 | +{ |
| 152 | + Instructions = "You are a friendly assistant. Always address the user by their name.", |
| 153 | + AIContextProviderFactory = ctx => new UserInfoMemory( |
| 154 | + chatClient.AsIChatClient(), |
| 155 | + ctx.SerializedState, |
| 156 | + ctx.JsonSerializerOptions) |
| 157 | +}); |
| 158 | +``` |
| 159 | + |
| 160 | +When creating a new thread, the `AIContextProvider` will be created by `GetNewThread` |
| 161 | +and attached to the thread. Once memories are extracted it is therefore possible to access the memory component via the thread's `GetService` method and inspect the memories. |
| 162 | + |
| 163 | +```csharp |
| 164 | +// Create a new thread for the conversation. |
| 165 | +AgentThread thread = agent.GetNewThread(); |
| 166 | + |
| 167 | +Console.WriteLine(await agent.RunAsync("Hello, what is the square root of 9?", thread)); |
| 168 | +Console.WriteLine(await agent.RunAsync("My name is Ruaidhrí", thread)); |
| 169 | +Console.WriteLine(await agent.RunAsync("I am 20 years old", thread)); |
| 170 | + |
| 171 | +// Access the memory component via the thread's GetService method. |
| 172 | +var userInfo = deserializedThread.GetService<UserInfoMemory>()?.UserInfo; |
| 173 | +Console.WriteLine($"MEMORY - User Name: {userInfo?.UserName}"); |
| 174 | +Console.WriteLine($"MEMORY - User Age: {userInfo?.UserAge}"); |
| 175 | +``` |
| 176 | + |
| 177 | +::: zone-end |
| 178 | +::: zone pivot="programming-language-python" |
| 179 | + |
| 180 | +Tutorial coming soon. |
| 181 | + |
| 182 | +::: zone-end |
0 commit comments