Skip to main content

MAF v1 — Sessions (Python + .NET)

Nitin Kumar Singh
Author
Nitin Kumar Singh
I build enterprise AI solutions and cloud-native systems. I write about architecture patterns, AI agents, Azure, and modern development practices — with full source code.
MAF v1 — Sessions (Python + .NET)
Table of Contents
MAF v1: Python and .NET - This article is part of a series.
Part 4: This Article

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, cd in, 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
#

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:

  1. Serialize — turn the session object into a JSON-shaped value (a Python dict, a .NET JsonElement).
  2. Persist — your code writes those bytes somewhere durable. MAF does not do the I/O; you pick the store.
  3. 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.
%%{init: {'theme':'base', 'themeVariables': { 'primaryColor': '#2563eb','primaryTextColor': '#ffffff','primaryBorderColor': '#1e40af', 'lineColor': '#64748b','secondaryColor': '#f59e0b','tertiaryColor': '#10b981', 'background': 'transparent'}}}%% flowchart LR classDef core fill:#2563eb,stroke:#1e40af,color:#ffffff classDef external fill:#f59e0b,stroke:#b45309,color:#000000 classDef success fill:#10b981,stroke:#047857,color:#ffffff classDef infra fill:#64748b,stroke:#334155,color:#ffffff start([Create session
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 on create_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. null for Chat Completions.
  • state — a free-form dict MAF hands to registered history providers. InMemoryHistoryProvider writes under the in_memory key; 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 a session_id, an optional service_session_id, and a state dict/bag. Class in both languages.
  • to_dict / from_dict (Python) — the pair of methods on AgentSession that serialize to a plain dict and reconstruct from one. JSON-safe by construction.
  • SerializeSessionAsync / DeserializeSessionAsync (.NET) — agent-level methods that turn a session into a JsonElement and back. Async because some backing providers may hit a service.
  • StateBag — the .NET shape that backs AgentSession.State. Conceptually identical to Python’s session.state dict.
  • 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 session state object; 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 into before_run / after_run to copy messages between the agent and session.state["in_memory"]["messages"]. Without it the session round-trips the session_id but not the turns.
  • _load_or_new shows the branching: file missing → fresh session; file present → rehydrate from disk. There is no middle ground — a partial/corrupted JSON raises out of AgentSession.from_dict and 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 the datetime and UUID fields 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: Teal

Between 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: Teal

Same observable behavior as Python, byte-for-byte the same answer from the same model.

Side-by-side differences
#

AspectPython.NET
Serializesession.to_dict()dictawait agent.SerializeSessionAsync(session)JsonElement
DeserializeAgentSession.from_dict(data)await agent.DeserializeSessionAsync(element)
Fresh sessionagent.create_session() (sync)await agent.CreateSessionAsync()
Who owns historyInMemoryHistoryProvider in context_providers=[...]Built into ChatClientAgent; no registration needed
Message storage locationsession.state["in_memory"]["messages"]Inside the agent’s view of session.State (StateBag)
Async surfaceOnly the run is async; serialization is syncSerialize/deserialize are both async
JSON layerjson.dumps / json.loadsJsonSerializer.Serialize / JsonDocument.Parse
Opaque handlesession_id: strAgentSession 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.

BackendFits whenSkip when
In-memoryUnit tests; single-process scripts that don’t need restart survival.Anything that crashes or scales out. State vanishes with the process.
File / JSONLLocal dev, CLI tools, single-host desktop apps. Inspectable in cat.Multi-instance backends — two workers on the same file race on the write.
PostgresProduction 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 storeServerless 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 InMemoryHistoryProvider on the Python side. Without it, session.to_dict() still produces valid JSON, but state is empty. The next process rehydrates the session_id and 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 state bag.
  • 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 await on the .NET serialize/deserialize. Both methods are Task<JsonElement> / Task<AgentSession>. Forgetting await compiles and silently hands you a Task reference that the rest of your code won’t recognise — the failure mode is weird null-reference errors instead of “you forgot await.”
  • JsonDocument is disposable. using var doc = await JsonDocument.ParseAsync(...) is not optional — the RootElement you hand to DeserializeSessionAsync is backed by the doc’s pooled memory. If you let the doc fall out of scope too early you’re reading freed memory.
  • default=str on json.dumps. MAF occasionally embeds UUID or datetime objects in state. Without default=str you’ll hit TypeError: Object of type X is not JSON serializable after 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.csproj

The 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 existing conversations / messages tables, used in production.
  • get_history_provider() (lines 201–221) — factory that switches on settings.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 same messages / conversations tables.
  • SessionProviderFactory.cs — picks a provider from AgentSettings.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

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 — the conversations and messages tables 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.

MAF v1: Python and .NET - This article is part of a series.
Part 4: This Article

Related