Back to homepage

Niels Kootstra

Remembering the conversation with agent sessions

Summarize with AI

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

The idea: a session doesn’t make the model remember; it sends the earlier turns again with every request.

Ask an agent what to cook with rice, eggs and an onion, then ask “And tomorrow?”, and it has no idea what you are talking about: the model keeps nothing between requests. In Microsoft Agent Framework, what sends the earlier turns back is an AgentSession. With one session per conversation, a console kitchen assistant follows the conversation and keeps it across a restart as a JSON file. A web endpoint loads a stored conversation only for the person who owns it.

Who this is for: C# developers who have built a first agent, as in Microsoft Agent Framework: your first C# agent.

Versions: Microsoft Agent Framework 1.24.0, .NET 10.

Code: samples/agent-framework/02-sessions, a runnable version of this post with its tests.

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.

Set up one agent on Claude Haiku

A console app with the Microsoft.Agents.AI and Microsoft.Agents.AI.Anthropic packages, and one agent on Claude Haiku 5.5:

Without a session, every call is the first

The second call sends the model the instructions and the words “And tomorrow?”, nothing else. Whatever it answers, it is not a follow-up to the rice and eggs, because the rice and eggs are not in the request.

RunAsync does not fall back on a shared default conversation when the session argument is left out. ChatClientAgent, the agent type AsAIAgent returns, creates a fresh session for that one call and drops it afterwards. That suits single-shot work such as classifying or summarizing a text. It breaks anything where the person gives information in pieces. A demo where one message happens to contain everything will not show the problem.

One session per conversation

CreateSessionAsync returns an empty session. Passing the same session to every call in a conversation is what connects the turns:

On the second call the agent sends the model the instructions, the first question, the first answer and the new question. The model can build on tonight’s dinner because the dinner is in front of it again, not because it remembered.

The fix in a real application is mechanical: create the session when the conversation starts, keep it with the conversation, and pass it to every call.

The session holds the messages, not the instructions

With this agent, the session holds the messages themselves. A history provider is the component that stores a conversation’s messages and hands them back on the next run. Unless it is given another one, ChatClientAgent uses InMemoryChatHistoryProvider, which stores each run’s request and response messages inside the session. TryGetInMemoryChatHistory reads them back:

The list has the user’s questions and the assistant’s answers, in order. The instructions are not in it. The agent adds them to every request from its own options, so changing the instructions changes the next turn of every stored conversation.

With a service that stores conversations itself, such as the OpenAI Responses API, the session holds only a conversation ID (ChatClientAgentSession.ConversationId) instead of the messages. To continue a conversation the service already has, ChatClientAgent.CreateSessionAsync(conversationId) creates a session around that ID.

Every turn resends the whole history

The history is not a summary. On each run the history provider hands the agent the full list, and the agent sends all of it to the model ahead of the new message. The token counts on each response show it:

The input token count climbs with every turn, because each request carries every earlier question and answer. Input tokens are billed, so a long conversation costs more per turn than a short one. Trimming or summarizing old history to keep that cost down is a separate problem. The session on its own keeps everything.

Save the session as JSON and restore it

A console app that exits loses its sessions, and so does a web server that restarts or runs on more than one machine. The agent turns a session into JSON and back:

The saved kitchen-session.json, formatted, with the long encrypted protectedData values cut to [...]:

Serialization belongs to the agent, not the session. SerializeSessionAsync and DeserializeSessionAsync are methods on AIAgent, and both are asynchronous: one returns a JsonElement, the other takes one.

The agent knows which providers wrote state into the session and how to read it back. Restore a session with an agent configured the same way. DeserializeSessionAsync does not check where the JSON came from: JSON written by a different kind of agent, or by an agent with other providers, loads without an error and the history is simply missing. What it refuses is JSON that is not an object, with an ArgumentException. A live session object of the wrong type is refused too: SerializeSessionAsync and RunAsync on a ChatClientAgent throw InvalidOperationException, saying that only sessions of type ChatClientAgentSession can be serialized or used by this agent.

The path is built from AppContext.BaseDirectory. A bare "kitchen-session.json" resolves against the current directory. That directory differs between dotnet run, the IDE and a deployed service, so the file can end up somewhere you don’t look.

Saving after every turn costs a write per turn, and a crash then loses at most the turn in progress. Saving only on exit loses everything since the last save when the process dies instead of exiting.

Open the file and the conversation is there in plain JSON. The stateBag object is the session’s dictionary of per-session state, and everything in it is saved with the session. It has an InMemoryChatHistoryProvider entry holding a messages list with every question and every answer. In this run the last two answers also carry a reasoning entry, an encrypted protectedData blob from the model, which is saved and sent back with the rest. The conversation itself makes the file personal data in most real applications. The API docs for SerializeSessionAsync call for access controls and encryption at rest wherever serialized sessions are stored. A “delete my data” request that clears the user table but not the session store is not finished.

Saving the whole session is not the only way to keep the history. A different ChatHistoryProvider, set through ChatClientAgentOptions.ChatHistoryProvider, writes the messages to a database itself, and the session’s state then holds only the key to find them. Agent Framework has one for Cosmos DB (Microsoft.Agents.AI.CosmosNoSql, preview) and one for Valkey (Microsoft.Agents.AI.Valkey, alpha).

Never load a session because the client named it

A web chat tells the server which conversation a message belongs to, usually with an ID the browser sends along. If the server loads whatever session that ID points to, anyone who changes the ID gets another household’s conversation loaded as context. A random ID makes guessing harder and does not fix the design.

Store each session with its owner and check the owner before loading. Agent Framework 1.24.0 does have a session store type, AgentSessionStore, but it is marked [Experimental], and the version that scopes sessions to a key such as a user ID is in the preview Microsoft.Agents.AI.Hosting package. The store below is application code, not a framework type:

A new conversation starts with StartAsync, which stores an empty session under an ID the server picks and records who owns it; every later message goes through ReplyAsync with that ID. The user ID comes from authentication, never from the request body. Answering 404 for someone else’s session, rather than 403, avoids confirming that the ID exists.

A stored session is untrusted input

The owner check protects reads. The store also needs protection against writes. The agent treats a restored history as its own. The API docs for DeserializeSessionAsync warn that a compromised store could change message roles to raise their trust, or add content that steers the model. So someone who edits a saved session can make the assistant appear to have said things it never said, and the next turn builds on that. A saved session is personal data when it is written, and untrusted input when it is read back.

Try it

  • Run five turns in one session and print InputTokenCount after each. The number only goes up.
  • Save the session, open the JSON file and find your own messages in it. Then delete the file and ask a follow-up question: the agent has lost the thread.