diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_MemoryProvider/Agent_Step21_MemoryProvider.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step21_MemoryProvider/Agent_Step21_MemoryProvider.csproj
new file mode 100644
index 0000000000..4800716f27
--- /dev/null
+++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_MemoryProvider/Agent_Step21_MemoryProvider.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_MemoryProvider/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step21_MemoryProvider/Program.cs
new file mode 100644
index 0000000000..43db255a60
--- /dev/null
+++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_MemoryProvider/Program.cs
@@ -0,0 +1,423 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample shows how to use an AIContextProvider as a persistent memory system for a ChatClientAgent.
+// The NovelContextMemory provider extracts structured novel context (title, genre, setting, characters, outline, etc.)
+// from each conversation turn and injects it back as additional AI context on subsequent turns.
+// This gives the agent persistent, structured memory across the entire conversation.
+// The provider extends AIContextProvider which provides built-in state management via the session's StateBag.
+
+using System.Text;
+using System.Text.Json;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using SampleApp;
+
+var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
+
+Console.OutputEncoding = Encoding.UTF8;
+
+// Create an IChatClient that we'll use both for the agent and for the memory extraction calls.
+// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
+// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
+IChatClient chatClient = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new DefaultAzureCredential())
+ .GetChatClient(deploymentName)
+ .AsIChatClient();
+
+// Create the novel memory provider. It uses AIContextProvider to persist structured
+// novel facts in the session's StateBag automatically across turns.
+var novelMemory = new NovelContextMemory(chatClient);
+
+// Create agent with novel memory attached as an AI context provider.
+AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
+{
+ Name = NovelInstructions.AgentName,
+ ChatOptions = new() { Instructions = NovelInstructions.Compose(NovelInstructions.WithNovelContext) },
+ AIContextProviders = [novelMemory],
+});
+
+// Create session and start interactive chat loop.
+AgentSession session = await agent.CreateSessionAsync();
+
+Console.WriteLine("Novel Seed Architect (with memory)");
+Console.WriteLine("Commands: /ctx (show memory), /exit\n");
+
+bool serializedOnce = false;
+
+while (true)
+{
+ Console.Write("You: ");
+ var input = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(input)) continue;
+ if (input.Equals("/exit", StringComparison.OrdinalIgnoreCase)) break;
+
+ // Show current memory state on demand.
+ if (input.Equals("/ctx", StringComparison.OrdinalIgnoreCase))
+ {
+ var ctx = session.StateBag.GetValue(nameof(NovelContextMemory));
+ Console.WriteLine(ctx?.ToPrettyString() ?? "(empty)");
+ continue;
+ }
+
+ // Stream the agent's response.
+ Console.Write("Agent: ");
+ await foreach (var update in agent.RunStreamingAsync(input, session))
+ {
+ if (!string.IsNullOrEmpty(update.Text))
+ Console.Write(update.Text);
+ }
+ Console.WriteLine("\n");
+
+ // Print memory after each turn so we can see what was extracted.
+ PrintMemory(session);
+
+ // After the first turn, demonstrate that NovelContext state survives session serialization.
+ if (!serializedOnce)
+ {
+ serializedOnce = true;
+ Console.WriteLine("[Demo] Verifying session serialization roundtrip...");
+ var serialized = await agent.SerializeSessionAsync(session);
+ session = await agent.DeserializeSessionAsync(serialized);
+ var restored = session.StateBag.GetValue(nameof(NovelContextMemory));
+ Console.WriteLine($"[Demo] Roundtrip OK — Title after restore: {restored?.Title ?? "(null)"}\n");
+ }
+}
+
+static void PrintMemory(AgentSession session)
+{
+ var ctx = session.StateBag.GetValue(nameof(NovelContextMemory));
+ Console.WriteLine("===========================================");
+ Console.WriteLine(ctx?.ToPrettyString() ?? "(empty)");
+ if (ctx?.OutlineSource is not null)
+ Console.WriteLine($" Outline source: ({ctx.OutlineSource})");
+ Console.WriteLine("===========================================\n");
+}
+
+namespace SampleApp
+{
+ ///
+ /// Agent instructions for the Novel Seed Architect.
+ ///
+ internal static class NovelInstructions
+ {
+ public const string AgentName = "NovelSeedArchitect";
+
+ public const string CoreInstructions = """
+ You are the Novel Seed Architect — an expert storytelling consultant who
+ transforms rough novel ideas into structured novel outlines.
+
+ When given a novel idea, produce this format:
+
+ ## Novel Outline
+
+ ### Title
+ [Working title for the novel]
+
+ ### Genre
+ [Primary genre and subgenres]
+
+ ### Setting
+ [Time period, world, locations — the stage where the story unfolds]
+
+ ### Protagonist
+ [Name, background, motivation, internal conflict]
+
+ ### Antagonist
+ [Name or force, nature, motivation, relationship to protagonist]
+
+ ### Theme
+ [Central theme and underlying message]
+
+ ### Synopsis
+ [Two to three paragraphs describing the arc — setup, confrontation, resolution]
+
+ ### Key Plot Points
+ - [ ] Inciting incident
+ - [ ] First turning point
+ - [ ] Midpoint reversal
+ - [ ] Crisis / dark moment
+ - [ ] Climax
+ - [ ] Resolution
+
+ Be vivid but concise. Always produce consistent, well-formatted output.
+ """;
+
+ public const string WithNovelContext = """
+
+ Important — Novel Memory:
+ - You have persistent memory. Novel facts (title, genre, setting, protagonist,
+ antagonist, theme) are accumulated across turns. Use them to ground every response.
+ - If key novel context is missing (see the "Missing context" section), proactively
+ ask 1-2 targeted questions to fill those gaps. Weave the questions naturally into
+ your response — for example: "Before I flesh out the antagonist, what genre are
+ you aiming for?" Do NOT wait for the user to volunteer this information.
+ - If a Novel Outline is present, treat it as work-in-progress.
+ Refine and improve it with each turn rather than starting from scratch.
+ Add sections that are missing, sharpen existing ones, incorporate new details.
+ - When you produce or update a novel outline, always output the full current
+ version so memory can capture it — even if some sections are still incomplete.
+
+ CRITICAL — Novel Outline Delimiters:
+ Whenever you output a novel outline (full or partial), you MUST wrap it
+ in these exact delimiter lines:
+ <<>>
+ (your full novel outline here)
+ <<>>
+ These delimiters are used by the memory system to capture and persist the outline.
+ Always include them — even when the outline is a rough draft or work-in-progress.
+ You may include additional commentary, questions, or analysis OUTSIDE the delimiters.
+ """;
+
+ public static string Compose(params string[] additions)
+ => string.Concat(CoreInstructions, string.Concat(additions));
+ }
+
+ ///
+ /// Structured record representing the current novel context (title, genre, characters, outline, etc.).
+ ///
+ internal sealed record NovelContext(
+ string? Title,
+ string? Genre,
+ string? Setting,
+ string? Protagonist,
+ string? Antagonist,
+ string? Theme,
+ string? Outline,
+ string? OutlineSource)
+ {
+ public static NovelContext Empty => new(null, null, null, null, null, null, null, null);
+
+ public string ToPrettyString()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($" Title: {this.Title ?? "(unknown)"}");
+ sb.AppendLine($" Genre: {this.Genre ?? "(unknown)"}");
+ sb.AppendLine($" Setting: {this.Setting ?? "(unknown)"}");
+ sb.AppendLine($" Protagonist: {this.Protagonist ?? "(unknown)"}");
+ sb.AppendLine($" Antagonist: {this.Antagonist ?? "(unknown)"}");
+ sb.AppendLine($" Theme: {this.Theme ?? "(unknown)"}");
+ if (this.Outline is not null)
+ {
+ var lines = this.Outline.Split('\n')
+ .Where(l => !string.IsNullOrWhiteSpace(l))
+ .Select(l => l.TrimStart('#', ' ', '-', '[', ']', '*'))
+ .Where(l => l.Length > 0)
+ .Take(6)
+ .ToList();
+ sb.AppendLine($" Outline: {lines.FirstOrDefault() ?? "(untitled)"}");
+ foreach (var line in lines.Skip(1))
+ sb.AppendLine($" {line}");
+ }
+ else
+ {
+ sb.AppendLine(" Outline: (none yet)");
+ }
+ return sb.ToString();
+ }
+ }
+
+ ///
+ /// Delta record used for structured extraction from LLM responses.
+ /// Internal (not public) because it is only used by for JSON deserialization.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization.")]
+ internal sealed record NovelContextDelta(
+ string? Title, string? Genre, string? Setting,
+ string? Protagonist, string? Antagonist, string? Theme,
+ string? Outline);
+
+ ///
+ /// An that acts as persistent memory for a novel-writing agent.
+ /// On each turn it injects the current novel context as additional instructions, and after each
+ /// turn it extracts structured novel facts from the conversation to update the context.
+ ///
+ internal sealed class NovelContextMemory : AIContextProvider
+ {
+ private readonly IChatClient _chatClient;
+
+ public NovelContextMemory(IChatClient chatClient)
+ : base(
+ stateInitializer: _ => NovelContext.Empty,
+ stateKey: null,
+ jsonSerializerOptions: null,
+ provideInputMessageFilter: null,
+ storeInputMessageFilter: null)
+ {
+ this._chatClient = chatClient;
+ }
+
+ protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ var state = this.GetOrInitializeState(context.Session);
+
+ var sb = new StringBuilder();
+ sb.AppendLine("## Novel Context (authoritative)");
+ if (!string.IsNullOrWhiteSpace(state.Title)) sb.AppendLine($"- Title: {state.Title}");
+ if (!string.IsNullOrWhiteSpace(state.Genre)) sb.AppendLine($"- Genre: {state.Genre}");
+ if (!string.IsNullOrWhiteSpace(state.Setting)) sb.AppendLine($"- Setting: {state.Setting}");
+ if (!string.IsNullOrWhiteSpace(state.Protagonist)) sb.AppendLine($"- Protagonist: {state.Protagonist}");
+ if (!string.IsNullOrWhiteSpace(state.Antagonist)) sb.AppendLine($"- Antagonist: {state.Antagonist}");
+ if (!string.IsNullOrWhiteSpace(state.Theme)) sb.AppendLine($"- Theme: {state.Theme}");
+ if (state.Outline is not null)
+ {
+ sb.AppendLine();
+ sb.AppendLine("## Novel Outline (work-in-progress)");
+ sb.AppendLine(state.Outline);
+ }
+
+ var missing = new List();
+ if (string.IsNullOrWhiteSpace(state.Title)) missing.Add("Title");
+ if (string.IsNullOrWhiteSpace(state.Genre)) missing.Add("Genre");
+ if (string.IsNullOrWhiteSpace(state.Setting)) missing.Add("Setting");
+ if (string.IsNullOrWhiteSpace(state.Protagonist)) missing.Add("Protagonist");
+ if (string.IsNullOrWhiteSpace(state.Antagonist)) missing.Add("Antagonist");
+ if (string.IsNullOrWhiteSpace(state.Theme)) missing.Add("Theme");
+ if (state.Outline is null) missing.Add("Outline");
+
+ if (missing.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine($"## Missing context — ask the user about: {string.Join(", ", missing)}");
+ }
+
+ return new ValueTask(new AIContext { Instructions = sb.ToString() });
+ }
+
+ private const string OutlineStart = "<<>>";
+ private const string OutlineEnd = "<<>>";
+
+ protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
+ {
+ if (!context.RequestMessages.Any(m => m.Role == ChatRole.User)) return;
+
+ var state = this.GetOrInitializeState(context.Session);
+ var responseMessages = (context.ResponseMessages ?? []).ToList();
+ var allMessages = context.RequestMessages.Concat(responseMessages).ToList();
+
+ var currentOutline = state.Outline;
+
+ const string outlineInstruction = """
+
+ ## Outline — Extract or update the novel outline:
+ Outline is a multiline string field containing the full novel outline.
+ The ASSISTANT's response may contain a novel outline wrapped between
+ <<>> and <<>> delimiters.
+ If those delimiters are present, copy ALL the content between them (excluding the
+ delimiter lines themselves) verbatim into Outline.
+ Preserve the full markdown content exactly as-is — do not summarize or truncate.
+ If the delimiters are NOT present in this turn, set Outline to null.
+ IMPORTANT: DO NOT INCLUDE THE DELIMITER LINES THEMSELVES.
+ """;
+
+ NovelContextDelta delta;
+ try
+ {
+ var extraction = await this._chatClient.GetResponseAsync(
+ allMessages,
+ new ChatOptions
+ {
+ Instructions = $"""
+ You are a precise information extraction assistant.
+ Extract structured data from the conversation messages.
+
+ ## Novel Facts (from USER messages):
+ - Title: the working title of the novel (null if not mentioned by the user)
+ - Genre: genre and subgenres mentioned (null if not mentioned by the user)
+ - Setting: time period, world, locations (null if not mentioned by the user)
+ - Protagonist: name, traits, motivation (null if not mentioned by the user)
+ - Antagonist: name or force, nature, motivation (null if not mentioned by the user)
+ - Theme: central theme or message (null if not mentioned by the user)
+ For the six fields above, return null if not mentioned.
+ Do NOT apply this null rule to Outline — that field has its own rules below.
+ {outlineInstruction}
+ """
+ },
+ cancellationToken: cancellationToken);
+
+ delta = extraction.Result;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // LLM extraction failed — fall back to delimiter parsing only.
+ // Memory update is best-effort; failures should not break the agent interaction.
+ Console.WriteLine($"[NovelContextMemory] Extraction failed, falling back to delimiter parsing: {ex.Message}");
+ delta = new NovelContextDelta(null, null, null, null, null, null, null);
+ }
+
+ // Outline extraction uses a two-phase strategy:
+ // 1. Primary: the LLM structured extraction (above) should capture the outline.
+ // 2. Fallback: if the LLM missed it, parse the raw response for <<>> delimiters.
+ // This ensures the outline is captured even when the LLM's structured output omits it.
+ var llmOutlineFound = !string.IsNullOrWhiteSpace(delta.Outline);
+
+ string? outlineToStore;
+ string? outlineSource = null;
+ if (llmOutlineFound)
+ {
+ outlineToStore = delta.Outline;
+ outlineSource = "llm";
+ }
+ else
+ {
+ // Fallback: parse delimiters directly from the assistant's response text.
+ var delimiterOutline = ExtractOutlineBetweenDelimiters(responseMessages);
+ if (delimiterOutline is not null)
+ {
+ outlineToStore = delimiterOutline;
+ outlineSource = "delimiter";
+ }
+ else
+ {
+ outlineToStore = currentOutline;
+ outlineSource = state.OutlineSource;
+ }
+ }
+
+ // Set the resolved outline on the delta so Merge() handles all fields uniformly.
+ delta = delta with { Outline = outlineToStore };
+ var newState = Merge(state, delta) with { OutlineSource = outlineSource };
+ _sessionState.SaveState(context.Session, newState);
+ }
+
+ private static string? ExtractOutlineBetweenDelimiters(IEnumerable messages)
+ {
+ var lastAssistant = messages.LastOrDefault(m => m.Role == ChatRole.Assistant);
+ if (lastAssistant is null) return null;
+
+ var text = lastAssistant.Text;
+ if (string.IsNullOrWhiteSpace(text)) return null;
+
+ var startIdx = text.IndexOf(OutlineStart, StringComparison.Ordinal);
+ if (startIdx < 0) return null;
+
+ var contentStart = startIdx + OutlineStart.Length;
+ var endIdx = text.IndexOf(OutlineEnd, contentStart, StringComparison.Ordinal);
+
+ var content = endIdx >= 0
+ ? text[contentStart..endIdx]
+ : text[contentStart..];
+
+ var trimmed = content.Trim();
+ return trimmed.Length > 0 ? trimmed : null;
+ }
+
+ private static NovelContext Merge(NovelContext current, NovelContextDelta delta)
+ {
+ return current with
+ {
+ Title = delta.Title ?? current.Title,
+ Genre = delta.Genre ?? current.Genre,
+ Setting = delta.Setting ?? current.Setting,
+ Protagonist = delta.Protagonist ?? current.Protagonist,
+ Antagonist = delta.Antagonist ?? current.Antagonist,
+ Theme = delta.Theme ?? current.Theme,
+ Outline = delta.Outline ?? current.Outline
+ };
+ }
+ }
+}