Series note — Chapter 04 of MAF v1: Python and .NET. The closest predecessor in the original Python-only series is Part 8 — Agent Memory: Remembering Across Conversations. That article leans into vector-based long-term memory; this one zooms in on the short-term session primitive MAF ships out of the box, and shows it in both languages.
Repo — Runnable code for this chapter: tutorials/04-sessions. Clone,
cdin, follow along.
Why this chapter#
Chapter 03 reused a session inside a single process. That works for a REPL; it falls over for anything that restarts — HTTP workers, background jobs, serverless functions, mobile clients reconnecting after a cold start.
The right answer in MAF is an AgentSession — a snapshot of everything the agent remembers about a conversation. Serialize it to JSON, write it to any store you like, reload it in a brand-new process, and the agent carries on as if the restart never happened.
This chapter teaches the serialize/deserialize round-trip end-to-end in both languages, and walks through how the capstone scales that same primitive across three pluggable backends (in-memory, file, Postgres) on each stack.
Prerequisites#
- Completed Chapter 03 — Streaming and Multi-turn.
.envat the repo root with eitherOPENAI_API_KEYor the Azure OpenAI trio.- Read-first (optional): Session overview.
The concept#
Every MAF agent run reads a session before it prompts the LLM and writes to that session after the response arrives. The session is the in-memory representation of “what this conversation remembers.” Persisting it across a process boundary is a three-step loop:
- Serialize — turn the session object into a JSON-shaped value (a Python
dict, a .NETJsonElement). - Persist — your code writes those bytes somewhere durable. MAF does not do the I/O; you pick the store.
- Deserialize — on the next process, read the bytes back and rehydrate them into a live session instance. Pass it to the next agent run and the prior turns are back in scope.
create_session / CreateSessionAsync]) turn1[Run turn 1
messages append to state] serialize[[Serialize
to_dict / SerializeSessionAsync]] store[(Backing store
InMemory - File - Postgres - Cosmos)] deserialize[[Deserialize
from_dict / DeserializeSessionAsync]] turn2[Run turn N
new messages, full history in scope] done([Conversation ends]) start --> turn1 turn1 --> serialize serialize --> store store -. crash - restart - migrate .-> deserialize deserialize --> turn2 turn2 --> serialize turn2 --> done class start,turn1,turn2 core class serialize,deserialize success class store infra class done success
The agent never sees the file system. You own the dotted edge — pick the store, pick the serialization format, pick the retention policy. MAF owns the shape of what gets serialized and how to rebuild a session from it.
Think of it as a state machine with a detour: the session sits in the InMemory state during a turn, takes a round trip through Serialized bytes on the way to a backing store, and returns to InMemory when the next process rehydrates it. Nothing about the agent code changes across that boundary — that’s the whole point of the primitive.
What’s inside a serialized session?#
Opaque is not the same as mysterious. When to_dict() / SerializeSessionAsync() writes, it emits a stable, documented JSON shape. Here’s what tutorials/04-sessions/python/session.json actually looks like after two turns against the live LLM:
{
"type": "session",
"session_id": "3b7a2468-86d7-47d9-9843-0ed91b97eb65",
"service_session_id": null,
"state": {
"in_memory": {
"messages": [
{
"type": "message",
"role": "user",
"contents": [{"type": "text", "text": "Remember: my favorite color is teal.", "additional_properties": {}}],
"additional_properties": {}
},
{
"type": "message",
"role": "assistant",
"contents": [{"type": "text", "text": "Got it! Your favorite color is teal.", "additional_properties": {}}],
"author_name": "stateful-agent",
"additional_properties": {}
}
]
}
}
}Four fields matter:
session_id— a UUID generated oncreate_session(). Your code treats this as a conversation handle.service_session_id— populated only when a provider-managed (server-side) session is in use, e.g. the OpenAI Responses API.nullfor Chat Completions.state— a free-form dict MAF hands to registered history providers.InMemoryHistoryProviderwrites under thein_memorykey; other providers (Postgres, Cosmos) can add their own sub-dicts without colliding.type— a discriminator MAF uses to validate the shape on load. Don’t edit it.
The .NET output is a JsonElement with the same top-level keys — same discriminator, same state bag, same messages.
You should treat the document as opaque for the purposes of your code (don’t parse state and mutate it), but it’s deliberately readable so you can eyeball it in logs and diffs.
Jargon recap#
AgentSession— the in-memory object carrying conversation state between agent runs. Has asession_id, an optionalservice_session_id, and astatedict/bag. Class in both languages.to_dict/from_dict(Python) — the pair of methods onAgentSessionthat serialize to a plaindictand reconstruct from one. JSON-safe by construction.SerializeSessionAsync/DeserializeSessionAsync(.NET) — agent-level methods that turn a session into aJsonElementand back. Async because some backing providers may hit a service.StateBag— the .NET shape that backsAgentSession.State. Conceptually identical to Python’ssession.statedict.ChatHistoryProvider— the interface that stores and retrieves conversation messages for a session. Swap it to change where history lives without touching agent code.InMemoryHistoryProvider— the default history provider. Keeps messages in the sessionstateobject; persists only if you persist the session itself.
Full definitions in the jargon glossary.
Python#
Full source: python/main.py. The core wiring:
# python/main.py (excerpt)
from agent_framework import Agent, AgentSession, InMemoryHistoryProvider
INSTRUCTIONS = "You are a helpful assistant. Keep answers short."
SESSION_FILE = pathlib.Path(__file__).resolve().parent / "session.json"
def build_agent(client=None) -> Agent:
return Agent(
client or _default_client(),
instructions=INSTRUCTIONS,
name="stateful-agent",
context_providers=[InMemoryHistoryProvider()], # <- the history piece
)
async def ask_and_save(agent: Agent, question: str, path: pathlib.Path) -> str:
session = _load_or_new(agent, path)
response = await agent.run(question, session=session)
_save(session, path)
return response.text
def _load_or_new(agent, path):
if path.exists():
return AgentSession.from_dict(json.loads(path.read_text()))
return agent.create_session()
def _save(session, path):
path.write_text(json.dumps(session.to_dict(), indent=2, default=str))Three things worth staring at:
InMemoryHistoryProvider()is a context provider. It hooks intobefore_run/after_runto copy messages between the agent andsession.state["in_memory"]["messages"]. Without it the session round-trips thesession_idbut not the turns._load_or_newshows the branching: file missing → fresh session; file present → rehydrate from disk. There is no middle ground — a partial/corrupted JSON raises out ofAgentSession.from_dictand you should let it, because “I kind of have your history” is worse than “I don’t have your history.”json.dumps(..., default=str)handles thedatetimeandUUIDfields MAF occasionally stuffs into state. Use it; don’t try to pre-stringify those fields yourself.
Run it (two process invocations):
cd tutorials/04-sessions/python
uv sync
uv run python main.py save "Remember: my favorite color is teal."
# Q: Remember: my favorite color is teal.
# A: Got it! Your favorite color is teal.
uv run python main.py load "What color did I tell you I liked? Answer with only the color."
# Q: What color did I tell you I liked? ...
# A: TealBetween the two commands the Python process exits fully. The second invocation reads session.json off disk, hands it to a brand-new Agent, and the answer proves the agent saw turn 1.
.NET#
Full source: dotnet/Program.cs. The shape is identical — different method names and async plumbing:
// dotnet/Program.cs (excerpt)
using Microsoft.Agents.AI;
using System.Text.Json;
public static AIAgent BuildAgent()
{
var chatClient = /* OpenAI or Azure OpenAI ChatClient */;
return chatClient.AsAIAgent(instructions: Instructions, name: "stateful-agent");
}
public static async Task<AgentSession> LoadOrNew(AIAgent agent, string path)
{
if (!File.Exists(path)) return await agent.CreateSessionAsync();
using var stream = File.OpenRead(path);
using var doc = await JsonDocument.ParseAsync(stream);
return await agent.DeserializeSessionAsync(doc.RootElement);
}
public static async Task Save(AIAgent agent, AgentSession session, string path)
{
var element = await agent.SerializeSessionAsync(session);
var json = JsonSerializer.Serialize(element, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(path, json);
}
public static async Task<(string Answer, string Path)> AskAndSave(
AIAgent agent, string question, string sessionPath)
{
var session = await LoadOrNew(agent, sessionPath);
var response = await agent.RunAsync(question, session);
await Save(agent, session, sessionPath);
return (response.Text, sessionPath);
}Notice what’s not there: no InMemoryHistoryProvider. .NET bundles the default history handling inside ChatClientAgent, so AsAIAgent(...) already wires the equivalent plumbing. Serializing the session picks up the messages without any extra registration. That’s the single biggest ergonomic difference between the two stacks for this chapter.
SerializeSessionAsync and DeserializeSessionAsync are async because nothing in the MAF contract says the provider is synchronous — a provider that reaches out to a managed Foundry session or a server-side Responses session needs to await.
Run it:
cd tutorials/04-sessions/dotnet
dotnet run -- save "Remember: my favorite color is teal."
dotnet run -- load "What color did I tell you I liked? Answer with only the color."
# Q: What color did I tell you I liked? ...
# A: TealSame observable behavior as Python, byte-for-byte the same answer from the same model.
Side-by-side differences#
| Aspect | Python | .NET |
|---|---|---|
| Serialize | session.to_dict() → dict | await agent.SerializeSessionAsync(session) → JsonElement |
| Deserialize | AgentSession.from_dict(data) | await agent.DeserializeSessionAsync(element) |
| Fresh session | agent.create_session() (sync) | await agent.CreateSessionAsync() |
| Who owns history | InMemoryHistoryProvider in context_providers=[...] | Built into ChatClientAgent; no registration needed |
| Message storage location | session.state["in_memory"]["messages"] | Inside the agent’s view of session.State (StateBag) |
| Async surface | Only the run is async; serialization is sync | Serialize/deserialize are both async |
| JSON layer | json.dumps / json.loads | JsonSerializer.Serialize / JsonDocument.Parse |
| Opaque handle | session_id: str | AgentSession with a SessionId property |
Python keeps the agent and the history provider loosely coupled — the advantage is that you can drop in a PostgresSessionHistoryProvider without changing the agent factory. .NET couples them tightly — the advantage is one fewer thing to register and forget.
Picking a backend#
The tutorial persists to a local file because it’s the smallest useful demo. In practice you pick a backend on two axes: process lifetime and deployment topology.
| Backend | Fits when | Skip when |
|---|---|---|
| In-memory | Unit tests; single-process scripts that don’t need restart survival. | Anything that crashes or scales out. State vanishes with the process. |
| File / JSONL | Local dev, CLI tools, single-host desktop apps. Inspectable in cat. | Multi-instance backends — two workers on the same file race on the write. |
| Postgres | Production web backends with a DB already in the stack. Indexable by session_id, transactional with the rest of your write path. | Ultra-low-latency inference where you can’t afford the round-trip. |
| Cosmos DB / document store | Serverless and multi-region deployments. JSON-native, auto-scaling, regional replicas. | Local dev without Cosmos emulator — annoying to set up. |
The Python and .NET capstones each ship all three of the first three — toggle via MAF_SESSION_BACKEND. Cosmos is covered in the MAF integrations docs and slots in as a fourth implementation of the same HistoryProvider / ISessionHistoryProvider contract.
Gotchas#
- Forgetting
InMemoryHistoryProvideron the Python side. Without it,session.to_dict()still produces valid JSON, butstateis empty. The next process rehydrates thesession_idand nothing else. The follow-up turn reads as a fresh conversation even though the file looks populated. - Don’t mix agents across sessions. A session produced by one agent (with its own tools, instructions, and providers) isn’t guaranteed to deserialize into a different agent. Treat the JSON as opaque — write it, load it, pass it back. Don’t hand it to an unrelated agent and don’t hand-edit the
statebag. - Size grows unbounded. Every turn appends messages. A week-long conversation serializes to megabytes. Long-lived sessions need summarization or a sliding window — Ch18 on checkpointing covers eviction patterns. For now, assume short-lived sessions.
- Always
awaiton the .NET serialize/deserialize. Both methods areTask<JsonElement>/Task<AgentSession>. Forgettingawaitcompiles and silently hands you aTaskreference that the rest of your code won’t recognise — the failure mode is weird null-reference errors instead of “you forgot await.” JsonDocumentis disposable.using var doc = await JsonDocument.ParseAsync(...)is not optional — theRootElementyou hand toDeserializeSessionAsyncis backed by the doc’s pooled memory. If you let the doc fall out of scope too early you’re reading freed memory.default=stronjson.dumps. MAF occasionally embedsUUIDordatetimeobjects in state. Withoutdefault=stryou’ll hitTypeError: Object of type X is not JSON serializableafter the first run.
Tests#
Both sides ship tests that exercise unit round-trip (no LLM) and integration persistence (fresh agent instances against the live LLM):
# Python — 4 unit tests (dict/JSON round-trip, fresh ids) + 1 integration
cd tutorials/04-sessions/python
uv run pytest -v
# .NET — 3 tests (cross-instance persistence, missing-file handling, LoadOrNew fallback)
cd tutorials/04-sessions/dotnet
dotnet test tests/Sessions.Tests.csprojThe interesting test in both suites is the one that builds agent1, saves a fact, builds a brand-new agent2, loads the session, and asserts the follow-up answer contains the remembered fact. That’s the test that proves the session primitive actually works across process boundaries — everything else is corner-case protection.
How this shows up in the capstone#
The tutorial’s file-backed round-trip is the idiomatic local-dev shape; production swaps the storage out without touching the session API.
Python — three pluggable backends at agents/python/shared/session.py:
InMemorySessionHistoryProvider(lines 50–80) — ephemeral dict, used in tests.FileSessionHistoryProvider(lines 82–128) — one JSONL file per session, for dev without a DB.PostgresSessionHistoryProvider(lines 131–195) — adapter over the existingconversations/messagestables, used in production.get_history_provider()(lines 201–221) — factory that switches onsettings.MAF_SESSION_BACKEND.
Tests covering all three live in agents/python/tests/test_session_roundtrip.py — the Postgres path uses an asyncpg-shaped fake pool so unit tests never hit a real DB.
.NET — same three backends under agents/dotnet/src/ECommerceAgents.Shared/Sessions/:
InMemorySessionHistoryProvider.cs— concurrent dict for tests.FileSessionHistoryProvider.cs— JSONL-per-session, matches the Python wire format.PostgresSessionHistoryProvider.cs— Dapper over the samemessages/conversationstables.SessionProviderFactory.cs— picks a provider fromAgentSettings.MafSessionBackend.
Tests in SessionProviderTests.cs mirror the Python suite — in-memory and file use pure unit tests; the Postgres path uses a testcontainers-backed fixture.
Swapping backends is a single env-var change (MAF_SESSION_BACKEND=memory|file|postgres) on either stack. You can run a local dev session against file, an integration test against memory, and production against postgres with the same orchestrator code untouched.
Further reading & links#
This chapter
- Canonical article: nitinksingh.com/posts/maf-v1-04-sessions/
- Source on GitHub: tutorials/04-sessions
- Previous: Chapter 03 — Streaming and Multi-turn · Next: Chapter 05 — Context Providers
Microsoft Agent Framework docs
Where it lives in the capstone
- Python:
agents/python/shared/session.py:50-221(three providers + factory) - .NET:
agents/dotnet/src/ECommerceAgents.Shared/Sessions/(three providers + factory) - Shared schema:
docker/postgres/init.sql— theconversationsandmessagestables both Postgres backends read and write.
Series shared resources
What’s next#
Chapter 05 — Context Providers — the provider pattern you saw in the Python factory (InMemoryHistoryProvider) is one instance of a broader mechanism. Context providers inject per-request state (user profile, retrieved documents, memories) into every agent turn without clobbering the conversation history you set up in this chapter.

