From 59de12cd13284b89b059d8c8f03810d33653dc53 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Fri, 13 Mar 2026 17:24:30 +0000
Subject: [PATCH 1/5] Persist messages during the Function Call Loop
---
dotnet/agent-framework-dotnet.slnx | 1 +
dotnet/global.json | 2 +-
..._Step19_InFunctionLoopCheckpointing.csproj | 20 +
.../Program.cs | 128 +++
.../README.md | 62 ++
dotnet/samples/02-agents/Agents/README.md | 1 +
.../ChatClient/ChatClientAgent.cs | 145 ++--
.../ChatClient/ChatClientAgentOptions.cs | 31 +
.../ChatClient/ChatClientAgentSession.cs | 16 +
.../ChatClient/ChatClientExtensions.cs | 11 +
.../ChatHistoryPersistingChatClient.cs | 195 +++++
.../SummarizationCompactionStrategy.cs | 2 +
.../ChatHistoryPersistingChatClientTests.cs | 733 ++++++++++++++++++
13 files changed, 1298 insertions(+), 49 deletions(-)
create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj
create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md
create mode 100644 dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 04fbb6cd87..3cfafb9a49 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -57,6 +57,7 @@
+
diff --git a/dotnet/global.json b/dotnet/global.json
index 42bb8863a3..482aa6b8d3 100644
--- a/dotnet/global.json
+++ b/dotnet/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.200",
+ "version": "10.0.100",
"rollForward": "minor",
"allowPrerelease": false
},
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj
new file mode 100644
index 0000000000..41aafe3437
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
new file mode 100644
index 0000000000..3a16c6a8e7
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
@@ -0,0 +1,128 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how the PersistChatHistoryAfterEachServiceCall option causes
+// chat history to be persisted after each individual call to the AI service, rather than
+// only at the end of the full agent run. When an agent uses tools, FunctionInvokingChatClient
+// loops multiple times (service call → tool execution → service call), and by default the
+// chat history is only persisted once the entire loop finishes. With this option enabled,
+// intermediate messages (tool calls and results) are persisted after each service call,
+// allowing you to inspect or recover them even if the process is interrupted mid-loop.
+//
+// The sample uses RunStreamingAsync so that we can observe the chat history growing
+// after each service call within a single agent run.
+
+using System.ComponentModel;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+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";
+
+// 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.
+AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
+IChatClient chatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
+
+// Define multiple tools so the model makes several tool calls in a single run.
+[Description("Get the current weather for a city.")]
+static string GetWeather([Description("The city name.")] string city) =>
+ city.ToUpperInvariant() switch
+ {
+ "SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
+ "NEW YORK" => "New York: 72°F, sunny and warm.",
+ "LONDON" => "London: 48°F, overcast with fog.",
+ _ => $"{city}: weather data not available."
+ };
+
+[Description("Get the current time in a city.")]
+static string GetTime([Description("The city name.")] string city) =>
+ city.ToUpperInvariant() switch
+ {
+ "SEATTLE" => "Seattle: 9:00 AM PST",
+ "NEW YORK" => "New York: 12:00 PM EST",
+ "LONDON" => "London: 5:00 PM GMT",
+ _ => $"{city}: time data not available."
+ };
+
+// Create the agent with PersistChatHistoryAfterEachServiceCall enabled.
+// The in-memory ChatHistoryProvider is used by default when no explicit provider is set,
+// so we can inspect the chat history via session.TryGetInMemoryChatHistory().
+AIAgent agent = chatClient.AsAIAgent(
+ new ChatClientAgentOptions
+ {
+ Name = "WeatherAssistant",
+ ChatOptions = new()
+ {
+ Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
+ Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)]
+ },
+ PersistChatHistoryAfterEachServiceCall = true,
+ });
+
+AgentSession session = await agent.CreateSessionAsync();
+
+// Ask about multiple cities — the model will need to call tools for each city,
+// resulting in multiple service calls within a single agent run.
+string prompt = "What's the weather and time in Seattle, New York, and London?";
+
+Console.ForegroundColor = ConsoleColor.Cyan;
+Console.Write("\n[User] ");
+Console.ResetColor();
+Console.WriteLine(prompt);
+
+PrintChatHistory("Before run");
+
+Console.ForegroundColor = ConsoleColor.Cyan;
+Console.Write("\n[Agent] ");
+Console.ResetColor();
+
+// Use RunStreamingAsync to observe the response as it streams.
+await foreach (var update in agent.RunStreamingAsync(prompt, session))
+{
+ Console.Write(update);
+}
+
+Console.WriteLine();
+
+PrintChatHistory("After run");
+
+// Run a second turn to show that chat history accumulated correctly.
+string followUp = "Which city is the warmest?";
+Console.ForegroundColor = ConsoleColor.Cyan;
+Console.Write("\n[User] ");
+Console.ResetColor();
+Console.WriteLine(followUp);
+
+Console.ForegroundColor = ConsoleColor.Cyan;
+Console.Write("\n[Agent] ");
+Console.ResetColor();
+
+await foreach (var update in agent.RunStreamingAsync(followUp, session))
+{
+ Console.Write(update);
+}
+
+Console.WriteLine();
+
+PrintChatHistory("After second run");
+
+// Helper to print the current chat history from the session.
+void PrintChatHistory(string label)
+{
+ if (session.TryGetInMemoryChatHistory(out var history))
+ {
+ Console.ForegroundColor = ConsoleColor.DarkGray;
+ Console.WriteLine($"\n [{label} — Chat history: {history.Count} message(s)]");
+ foreach (var msg in history)
+ {
+ var preview = msg.Text?.Length > 80 ? msg.Text[..80] + "…" : msg.Text;
+ var contentTypes = string.Join(", ", msg.Contents.Select(c => c.GetType().Name));
+ Console.WriteLine($" {msg.Role,-12} | {preview ?? $"[{contentTypes}]"}");
+ }
+
+ Console.ResetColor();
+ }
+}
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md
new file mode 100644
index 0000000000..3c3e8a2c30
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md
@@ -0,0 +1,62 @@
+# In-Function-Loop Checkpointing
+
+This sample demonstrates how the `PersistChatHistoryAfterEachServiceCall` option on `ChatClientAgentOptions` causes chat history to be saved after each individual call to the AI service, rather than only at the end of the full agent run.
+
+## What This Sample Shows
+
+When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By default, chat history is only persisted once the entire loop finishes. With `PersistChatHistoryAfterEachServiceCall` enabled:
+
+- A `ChatHistoryPersistingChatClient` decorator is automatically inserted into the chat client pipeline
+- After each service call, the decorator notifies the `ChatHistoryProvider` (and any `AIContextProvider` instances) with the new messages
+- Only **new** messages are sent to providers on each notification — messages that were already persisted in an earlier call within the same run are deduplicated automatically
+- The end-of-run persistence in `ChatClientAgent` is skipped to avoid double-persisting
+
+This is useful for:
+- **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted
+- **Observability** — you can inspect the chat history while the agent is still running (e.g., during streaming)
+- **Long-running tool loops** — agents with many sequential tool calls benefit from incremental persistence
+
+## How It Works
+
+The sample asks the agent about the weather and time in three cities. The model calls the `GetWeather` and `GetTime` tools for each city, resulting in multiple service calls within a single `RunStreamingAsync` invocation. After the run completes, the sample prints the full chat history to show all the intermediate messages that were persisted along the way.
+
+### Pipeline Architecture
+
+```
+ChatClientAgent
+ └─ FunctionInvokingChatClient (handles tool call loop)
+ └─ ChatHistoryPersistingChatClient (persists after each service call)
+ └─ Leaf IChatClient (Azure OpenAI)
+```
+
+## Prerequisites
+
+- .NET 10 SDK or later
+- Azure OpenAI service endpoint and model deployment
+- Azure CLI installed and authenticated
+
+**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
+
+## Environment Variables
+
+```powershell
+$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required
+$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
+```
+
+## Running the Sample
+
+```powershell
+cd dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing
+dotnet run
+```
+
+## Expected Behavior
+
+The sample runs two conversation turns:
+
+1. **First turn** — asks about weather and time in three cities. The model calls `GetWeather` and `GetTime` tools (potentially in parallel or sequentially), then provides a summary. The chat history dump after the run shows all the intermediate tool call and result messages.
+
+2. **Second turn** — asks a follow-up question ("Which city is the warmest?") that uses the persisted conversation context. The chat history dump shows the full accumulated conversation.
+
+The chat history printout uses `session.TryGetInMemoryChatHistory()` to inspect the in-memory storage.
diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md
index 4ac53ba246..c5258ba9f4 100644
--- a/dotnet/samples/02-agents/Agents/README.md
+++ b/dotnet/samples/02-agents/Agents/README.md
@@ -45,6 +45,7 @@ Before you begin, ensure you have the following prerequisites:
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
+|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
## Running the samples from the console
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
index adb6eb9f83..bba8c474ef 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
@@ -212,13 +212,18 @@ protected override async Task RunCoreAsync(
await this.PrepareSessionAndMessagesAsync(session, inputMessages, options, cancellationToken).ConfigureAwait(false);
var chatClient = this.ChatClient;
-
chatClient = ApplyRunOptionsTransformations(options, chatClient);
var loggingAgentName = this.GetLoggingAgentName();
-
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType);
+ // Initialize the per-service-call message tracking if the decorator is being used.
+ if (this.PersistsChatHistoryPerServiceCall)
+ {
+ safeSession.NotifiedMessages ??= new();
+ safeSession.NotifiedMessages.Clear();
+ }
+
// Call the IChatClient and notify the AIContextProvider of any failures.
ChatResponse chatResponse;
try
@@ -227,10 +232,14 @@ protected override async Task RunCoreAsync(
}
catch (Exception ex)
{
- await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
- await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, inputMessagesForChatClient, cancellationToken).ConfigureAwait(false);
+ await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
+ finally
+ {
+ // Clear the per-service-call message tracking now that the run is complete (or failed).
+ safeSession.NotifiedMessages?.Clear();
+ }
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType, inputMessages.Count);
@@ -244,11 +253,8 @@ protected override async Task RunCoreAsync(
chatResponseMessage.AuthorName ??= this.Name;
}
- // Only notify the session of new messages if the chatResponse was successful to avoid inconsistent message state in the session.
- await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
-
- // Notify the AIContextProvider of all new messages.
- await this.NotifyAIContextProviderOfSuccessAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
+ // Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
+ await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
return new AgentResponse(chatResponse)
{
@@ -304,6 +310,13 @@ protected override async IAsyncEnumerable RunCoreStreamingA
this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
+ // Initialize the per-service-call message tracking if the decorator is being used.
+ if (this.PersistsChatHistoryPerServiceCall)
+ {
+ safeSession.NotifiedMessages ??= new();
+ safeSession.NotifiedMessages.Clear();
+ }
+
List responseUpdates = GetResponseUpdates(continuationToken);
IAsyncEnumerator responseUpdatesEnumerator;
@@ -315,8 +328,8 @@ protected override async IAsyncEnumerable RunCoreStreamingA
}
catch (Exception ex)
{
- await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
- await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false);
+ safeSession.NotifiedMessages?.Clear();
+ await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -330,8 +343,8 @@ protected override async IAsyncEnumerable RunCoreStreamingA
}
catch (Exception ex)
{
- await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
- await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false);
+ safeSession.NotifiedMessages?.Clear();
+ await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -357,8 +370,8 @@ protected override async IAsyncEnumerable RunCoreStreamingA
}
catch (Exception ex)
{
- await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
- await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false);
+ safeSession.NotifiedMessages?.Clear();
+ await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
}
@@ -369,11 +382,11 @@ protected override async IAsyncEnumerable RunCoreStreamingA
// so let's update it and set the conversation id for the service session case.
this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken);
- // To avoid inconsistent state we only notify the session of the input messages if no error occurs after the initial request.
- await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
+ // Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
+ await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
- // Notify the AIContextProvider of all new messages.
- await this.NotifyAIContextProviderOfSuccessAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, cancellationToken).ConfigureAwait(false);
+ // Clear the per-service-call message tracking now that the run is complete.
+ safeSession.NotifiedMessages?.Clear();
}
///
@@ -441,17 +454,29 @@ protected override ValueTask DeserializeSessionCoreAsync(JsonEleme
#region Private
///
- /// Notify the when an agent run succeeded, if there is an .
+ /// Notifies the and all of successfully completed messages.
///
- private async Task NotifyAIContextProviderOfSuccessAsync(
+ ///
+ /// This method is also called by to persist messages per-service-call.
+ ///
+ internal async Task NotifyProvidersOfNewMessagesAsync(
ChatClientAgentSession session,
- IEnumerable inputMessages,
+ IEnumerable requestMessages,
IEnumerable responseMessages,
+ ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
+ ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, session);
+
+ if (chatHistoryProvider is not null)
+ {
+ var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, responseMessages);
+ await chatHistoryProvider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
+ }
+
if (this.AIContextProviders is { Count: > 0 } contextProviders)
{
- AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages, responseMessages);
+ AIContextProvider.InvokedContext invokedContext = new(this, session, requestMessages, responseMessages);
foreach (var contextProvider in contextProviders)
{
@@ -461,17 +486,29 @@ private async Task NotifyAIContextProviderOfSuccessAsync(
}
///
- /// Notify the of any failure during an agent run, if there is an .
+ /// Notifies the and all of a failure during a service call.
///
- private async Task NotifyAIContextProviderOfFailureAsync(
+ ///
+ /// This method is also called by to report failures per-service-call.
+ ///
+ internal async Task NotifyProvidersOfFailureAsync(
ChatClientAgentSession session,
Exception ex,
- IEnumerable inputMessages,
+ IEnumerable requestMessages,
+ ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
+ ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, session);
+
+ if (chatHistoryProvider is not null)
+ {
+ var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, ex);
+ await chatHistoryProvider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
+ }
+
if (this.AIContextProviders is { Count: > 0 } contextProviders)
{
- AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages, ex);
+ AIContextProvider.InvokedContext invokedContext = new(this, session, requestMessages, ex);
foreach (var contextProvider in contextProviders)
{
@@ -798,47 +835,59 @@ private void UpdateSessionConversationId(ChatClientAgentSession session, string?
}
}
- private Task NotifyChatHistoryProviderOfFailureAsync(
+ ///
+ /// Notifies providers of successfully completed messages at the end of an agent run.
+ ///
+ ///
+ /// When is , the
+ /// decorator handles per-service-call notification,
+ /// so this end-of-run notification is skipped.
+ ///
+ private Task NotifyProvidersOfNewMessagesAtEndOfRunAsync(
ChatClientAgentSession session,
- Exception ex,
IEnumerable requestMessages,
+ IEnumerable responseMessages,
ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
- ChatHistoryProvider? provider = this.ResolveChatHistoryProvider(chatOptions, session);
-
- // Only notify the provider if we have one.
- // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
- if (provider is not null)
+ if (this.PersistsChatHistoryPerServiceCall)
{
- var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, ex);
-
- return provider.InvokedAsync(invokedContext, cancellationToken).AsTask();
+ return Task.CompletedTask;
}
- return Task.CompletedTask;
+ return this.NotifyProvidersOfNewMessagesAsync(session, requestMessages, responseMessages, chatOptions, cancellationToken);
}
- private Task NotifyChatHistoryProviderOfNewMessagesAsync(
+ ///
+ /// Notifies providers of a failure at the end of an agent run.
+ ///
+ ///
+ /// When is , the
+ /// decorator handles per-service-call notification,
+ /// so this end-of-run notification is skipped.
+ ///
+ private Task NotifyProvidersOfFailureAtEndOfRunAsync(
ChatClientAgentSession session,
+ Exception ex,
IEnumerable requestMessages,
- IEnumerable responseMessages,
ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
- ChatHistoryProvider? provider = this.ResolveChatHistoryProvider(chatOptions, session);
-
- // Only notify the provider if we have one.
- // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
- if (provider is not null)
+ if (this.PersistsChatHistoryPerServiceCall)
{
- var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, responseMessages);
- return provider.InvokedAsync(invokedContext, cancellationToken).AsTask();
+ return Task.CompletedTask;
}
- return Task.CompletedTask;
+ return this.NotifyProvidersOfFailureAsync(session, ex, requestMessages, chatOptions, cancellationToken);
}
+ ///
+ /// Gets a value indicating whether the agent is configured to persist chat history after each individual service call
+ /// via a decorator.
+ ///
+ private bool PersistsChatHistoryPerServiceCall =>
+ this._agentOptions?.PersistChatHistoryAfterEachServiceCall is true && this._agentOptions?.UseProvidedChatClientAsIs is not true;
+
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session)
{
ChatHistoryProvider? provider = session.ConversationId is null ? this.ChatHistoryProvider : null;
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
index 38cad40bbe..b9c23e62ad 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -89,6 +91,34 @@ public sealed class ChatClientAgentOptions
///
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
+ ///
+ /// Gets or sets a value indicating whether to persist chat history after each individual service call
+ /// rather than only at the end of the full agent run.
+ ///
+ ///
+ ///
+ /// By default, persists request and response messages via the
+ /// only after the full run completes, which may include multiple
+ /// iterations of the function invocation loop. Setting this property to causes
+ /// messages to be persisted after each individual call to the underlying AI service, so that intermediate
+ /// messages (e.g., tool calls and results) are saved even if the process is interrupted mid-loop.
+ ///
+ ///
+ /// When this option is enabled, a decorator is automatically
+ /// inserted into the chat client pipeline between the and the
+ /// leaf , and the will not perform its own
+ /// end-of-run chat history persistence to avoid double-persisting messages.
+ ///
+ ///
+ /// This option has no effect when is .
+ ///
+ ///
+ ///
+ /// Default is .
+ ///
+ [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+ public bool PersistChatHistoryAfterEachServiceCall { get; set; }
+
///
/// Creates a new instance of with the same values as this instance.
///
@@ -105,5 +135,6 @@ public ChatClientAgentOptions Clone()
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
+ PersistChatHistoryAfterEachServiceCall = this.PersistChatHistoryAfterEachServiceCall,
};
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs
index 400bfbcaf6..f7093472e9 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs
@@ -1,9 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
+using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -88,4 +90,18 @@ internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = nu
private string DebuggerDisplay =>
this.ConversationId is { } conversationId ? $"ConversationId = {conversationId}, StateBag Count = {this.StateBag.Count}" :
$"StateBag Count = {this.StateBag.Count}";
+
+ ///
+ /// Gets or sets the set of instances that have already been notified to providers
+ /// during the current agent run. Used by to avoid duplicate
+ /// notifications when loops cause the same messages to be passed
+ /// across multiple service calls.
+ ///
+ ///
+ /// This set is cleared at the start and end of each run. It uses reference equality
+ /// to track message identity since reuses the same message objects
+ /// across loop iterations.
+ ///
+ [JsonIgnore]
+ internal HashSet? NotifiedMessages { get; set; }
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
index 8290c39974..03c283f74e 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
@@ -63,6 +63,17 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
});
}
+ // ChatHistoryPersistingChatClient is registered after FunctionInvokingChatClient so that it sits
+ // between FIC and the leaf client. ChatClientBuilder.Build applies factories in reverse order,
+ // making the first Use() call outermost. By adding our decorator second, the resulting pipeline is:
+ // FunctionInvokingChatClient → ChatHistoryPersistingChatClient → leaf IChatClient
+ // This allows the decorator to persist messages after each individual service call within
+ // FIC's function invocation loop.
+ if (options?.PersistChatHistoryAfterEachServiceCall is true)
+ {
+ chatBuilder.Use(innerClient => new ChatHistoryPersistingChatClient(innerClient));
+ }
+
var agentChatClient = chatBuilder.Build(services);
if (options?.ChatOptions?.Tools is { Count: > 0 })
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
new file mode 100644
index 0000000000..0b4d02c95f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
@@ -0,0 +1,195 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A delegating chat client that notifies and
+/// instances of request and response messages after each individual call to the inner chat client.
+///
+///
+///
+/// This decorator is intended to operate between the and the leaf
+/// in a pipeline. It ensures that providers are notified
+/// after each service call rather than only at the end of the full agent run, so that intermediate messages
+/// (e.g., tool calls and results) are saved even if the process is interrupted mid-loop.
+///
+///
+/// This chat client must be used within the context of a running . It retrieves the
+/// current agent and session from , which is set automatically when an agent's
+/// or
+///
+/// method is called. An is thrown if no run context is available or if the
+/// agent is not a .
+///
+///
+internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The underlying chat client that will handle the core operations.
+ public ChatHistoryPersistingChatClient(IChatClient innerClient)
+ : base(innerClient)
+ {
+ }
+
+ ///
+ public override async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var (agent, session) = GetRequiredAgentAndSession();
+
+ ChatResponse response;
+ try
+ {
+ response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ var newRequestMessagesOnFailure = GetNewMessages(messages, session);
+ MarkAsNotified(newRequestMessagesOnFailure, session);
+ await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+
+ var newRequestMessages = GetNewMessages(messages, session);
+ MarkAsNotified(newRequestMessages, session);
+ MarkAsNotified(response.Messages, session);
+ await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, response.Messages, options, cancellationToken).ConfigureAwait(false);
+
+ return response;
+ }
+
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var (agent, session) = GetRequiredAgentAndSession();
+
+ List responseUpdates = [];
+
+ IAsyncEnumerator enumerator;
+ try
+ {
+ enumerator = base.GetStreamingResponseAsync(messages, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ var newRequestMessagesOnFailure = GetNewMessages(messages, session);
+ MarkAsNotified(newRequestMessagesOnFailure, session);
+ await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+
+ bool hasUpdates;
+ try
+ {
+ hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ var newRequestMessagesOnFailure = GetNewMessages(messages, session);
+ MarkAsNotified(newRequestMessagesOnFailure, session);
+ await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+
+ while (hasUpdates)
+ {
+ var update = enumerator.Current;
+ responseUpdates.Add(update);
+ yield return update;
+
+ try
+ {
+ hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ var newRequestMessagesOnFailure = GetNewMessages(messages, session);
+ MarkAsNotified(newRequestMessagesOnFailure, session);
+ await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ var chatResponse = responseUpdates.ToChatResponse();
+ var newRequestMessages = GetNewMessages(messages, session);
+ MarkAsNotified(newRequestMessages, session);
+ MarkAsNotified(chatResponse.Messages, session);
+ await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, chatResponse.Messages, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Gets the current and from the run context.
+ ///
+ private static (ChatClientAgent Agent, ChatClientAgentSession Session) GetRequiredAgentAndSession()
+ {
+ var runContext = AIAgent.CurrentRunContext
+ ?? throw new InvalidOperationException(
+ $"{nameof(ChatHistoryPersistingChatClient)} can only be used within the context of a running AIAgent. " +
+ "Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
+
+ if (runContext.Agent is not ChatClientAgent chatClientAgent)
+ {
+ throw new InvalidOperationException(
+ $"{nameof(ChatHistoryPersistingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " +
+ $"The current agent is of type '{runContext.Agent.GetType().Name}'.");
+ }
+
+ if (runContext.Session is not ChatClientAgentSession chatClientAgentSession)
+ {
+ throw new InvalidOperationException(
+ $"{nameof(ChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
+ $"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'.");
+ }
+
+ return (chatClientAgent, chatClientAgentSession);
+ }
+
+ ///
+ /// Filters the given messages to return only those that have not yet been notified to providers
+ /// during the current agent run.
+ ///
+ /// The full set of messages to filter.
+ /// The current session containing the set of already-notified messages.
+ /// A list of messages that have not yet been notified. If no tracking is available, all messages are returned.
+ private static IReadOnlyList GetNewMessages(IEnumerable messages, ChatClientAgentSession session)
+ {
+ HashSet? notifiedMessages = session.NotifiedMessages;
+ if (notifiedMessages is null or { Count: 0 })
+ {
+ return messages as IReadOnlyList ?? messages.ToList();
+ }
+
+ return messages.Where(m => !notifiedMessages.Contains(m)).ToList();
+ }
+
+ ///
+ /// Marks the given messages as notified so they will be excluded from future notifications in the current run.
+ ///
+ /// The messages to mark as notified.
+ /// The current session containing the set of already-notified messages.
+ private static void MarkAsNotified(IEnumerable messages, ChatClientAgentSession session)
+ {
+ if (session.NotifiedMessages is { } notifiedMessages)
+ {
+ foreach (var message in messages)
+ {
+ notifiedMessages.Add(message);
+ }
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
index 9ff7ecf405..1f7f486e23 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
@@ -11,6 +11,8 @@
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
+#pragma warning disable CA1873
+
namespace Microsoft.Agents.AI.Compaction;
///
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
new file mode 100644
index 0000000000..54bd42910e
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
@@ -0,0 +1,733 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using Moq.Protected;
+
+namespace Microsoft.Agents.AI.UnitTests;
+
+///
+/// Contains unit tests for the decorator,
+/// verifying that it persists messages via the after each
+/// individual service call when the
+/// option is enabled.
+///
+public class ChatHistoryPersistingChatClientTests
+{
+ ///
+ /// Verifies that when PersistChatHistoryAfterEachServiceCall is enabled,
+ /// the ChatHistoryProvider receives messages after a successful non-streaming call.
+ ///
+ [Fact]
+ public async Task RunAsync_PersistsMessagesPerServiceCall_WhenOptionEnabledAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAfterEachServiceCall = true,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+
+ // Assert — InvokedCoreAsync should be called by the decorator (per service call)
+ mockChatHistoryProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.RequestMessages.Any(m => m.Text == "test") &&
+ x.ResponseMessages!.Any(m => m.Text == "response")),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when PersistChatHistoryAfterEachServiceCall is disabled (default),
+ /// the ChatHistoryProvider still receives messages at end-of-run as before.
+ ///
+ [Fact]
+ public async Task RunAsync_PersistsMessagesAtEndOfRun_WhenOptionDisabledAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAfterEachServiceCall = false,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+
+ // Assert — InvokedCoreAsync should be called once by the agent (end of run)
+ mockChatHistoryProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.RequestMessages.Any(m => m.Text == "test") &&
+ x.ResponseMessages!.Any(m => m.Text == "response")),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when PersistChatHistoryAfterEachServiceCall is enabled and the service call fails,
+ /// the ChatHistoryProvider is notified with the exception.
+ ///
+ [Fact]
+ public async Task RunAsync_NotifiesProviderOfFailure_WhenOptionEnabledAndServiceFailsAsync()
+ {
+ // Arrange
+ var expectedException = new InvalidOperationException("Service failed");
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ThrowsAsync(expectedException);
+
+ Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAfterEachServiceCall = true,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session));
+
+ // Assert — the decorator should have notified the provider of the failure
+ mockChatHistoryProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.InvokeException != null &&
+ x.InvokeException.Message == "Service failed"),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that the decorator is injected into the pipeline when the option is set
+ /// and can be discovered via GetService.
+ ///
+ [Fact]
+ public void ChatClient_ContainsDecorator_WhenOptionEnabled()
+ {
+ // Arrange
+ Mock mockService = new();
+
+ // Act
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ PersistChatHistoryAfterEachServiceCall = true,
+ });
+
+ // Assert
+ var decorator = agent.ChatClient.GetService();
+ Assert.NotNull(decorator);
+ }
+
+ ///
+ /// Verifies that the decorator is NOT injected into the pipeline when the option is not set.
+ ///
+ [Fact]
+ public void ChatClient_DoesNotContainDecorator_WhenOptionDisabled()
+ {
+ // Arrange
+ Mock mockService = new();
+
+ // Act
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ PersistChatHistoryAfterEachServiceCall = false,
+ });
+
+ // Assert
+ var decorator = agent.ChatClient.GetService();
+ Assert.Null(decorator);
+ }
+
+ ///
+ /// Verifies that the decorator is NOT injected when UseProvidedChatClientAsIs is true,
+ /// even if PersistChatHistoryAfterEachServiceCall is also true.
+ ///
+ [Fact]
+ public void ChatClient_DoesNotContainDecorator_WhenUseProvidedChatClientAsIs()
+ {
+ // Arrange
+ Mock mockService = new();
+
+ // Act
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ PersistChatHistoryAfterEachServiceCall = true,
+ UseProvidedChatClientAsIs = true,
+ });
+
+ // Assert
+ var decorator = agent.ChatClient.GetService();
+ Assert.Null(decorator);
+ }
+
+ ///
+ /// Verifies that the PersistChatHistoryAfterEachServiceCall option is included in Clone().
+ ///
+ [Fact]
+ public void ChatClientAgentOptions_Clone_IncludesPersistChatHistoryAfterEachServiceCall()
+ {
+ // Arrange
+ var options = new ChatClientAgentOptions
+ {
+ PersistChatHistoryAfterEachServiceCall = true,
+ };
+
+ // Act
+ var cloned = options.Clone();
+
+ // Assert
+ Assert.True(cloned.PersistChatHistoryAfterEachServiceCall);
+ }
+
+ ///
+ /// Verifies that when PersistChatHistoryAfterEachServiceCall is enabled and the service call
+ /// involves a function invocation loop, the ChatHistoryProvider is called after each individual
+ /// service call (not just once at the end).
+ ///
+ [Fact]
+ public async Task RunAsync_PersistsPerServiceCall_DuringFunctionInvocationLoopAsync()
+ {
+ // Arrange
+ int serviceCallCount = 0;
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(() =>
+ {
+ serviceCallCount++;
+ if (serviceCallCount == 1)
+ {
+ // First call returns a tool call
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())])]));
+ }
+
+ // Second call returns a final response
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
+ });
+
+ var invokedContexts = new List();
+
+ Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
+ .Returns(() => new ValueTask());
+
+ // Define a simple tool
+ var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatOptions = new() { Tools = [tool] },
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAfterEachServiceCall = true,
+ }, services: new ServiceCollection().BuildServiceProvider());
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ Exception? caughtException = null;
+ try
+ {
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+ }
+ catch (Exception ex)
+ {
+ caughtException = ex;
+ }
+
+ // Diagnostic: check if there was an unexpected exception
+ Assert.Null(caughtException);
+
+ // Assert — the decorator should have been called twice (once per service call in the function invocation loop)
+ Assert.Equal(2, serviceCallCount);
+ Assert.Equal(2, invokedContexts.Count);
+
+ // First invocation should have the user message as request and tool call response
+ Assert.NotNull(invokedContexts[0].ResponseMessages);
+ var firstRequestMessages = invokedContexts[0].RequestMessages.ToList();
+ Assert.Contains(firstRequestMessages, m => m.Text == "test");
+ Assert.Contains(invokedContexts[0].ResponseMessages!, m => m.Contents.OfType().Any());
+
+ // Second invocation: request messages should NOT include the original user message (already notified).
+ // It should only include messages added since the first call (assistant tool call + tool result).
+ Assert.NotNull(invokedContexts[1].ResponseMessages);
+ var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
+ Assert.DoesNotContain(secondRequestMessages, m => m.Text == "test");
+ Assert.Contains(invokedContexts[1].ResponseMessages!, m => m.Text == "final response");
+ }
+
+ ///
+ /// Verifies that when PersistChatHistoryAfterEachServiceCall is enabled with streaming,
+ /// the ChatHistoryProvider receives messages after the stream completes.
+ ///
+ [Fact]
+ public async Task RunStreamingAsync_PersistsMessagesPerServiceCall_WhenOptionEnabledAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetStreamingResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(CreateAsyncEnumerableAsync(
+ new ChatResponseUpdate(ChatRole.Assistant, "streaming "),
+ new ChatResponseUpdate(ChatRole.Assistant, "response")));
+
+ Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAfterEachServiceCall = true,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
+ {
+ // Consume stream
+ }
+
+ // Assert — InvokedCoreAsync should be called by the decorator
+ mockChatHistoryProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.RequestMessages.Any(m => m.Text == "test") &&
+ x.ResponseMessages != null),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when PersistChatHistoryAfterEachServiceCall is enabled,
+ /// AIContextProviders are also notified of new messages after a successful call.
+ ///
+ [Fact]
+ public async Task RunAsync_NotifiesAIContextProviders_WhenOptionEnabledAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockContextProvider = new((object?)null, (object?)null, (object?)null);
+ mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
+ mockContextProvider
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask(new AIContext()));
+ mockContextProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ AIContextProviders = [mockContextProvider.Object],
+ PersistChatHistoryAfterEachServiceCall = true,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+
+ // Assert — InvokedCoreAsync should be called by the decorator for the AIContextProvider
+ mockContextProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.ResponseMessages != null &&
+ x.ResponseMessages.Any(m => m.Text == "response")),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when PersistChatHistoryAfterEachServiceCall is enabled and the service fails,
+ /// AIContextProviders are notified of the failure.
+ ///
+ [Fact]
+ public async Task RunAsync_NotifiesAIContextProvidersOfFailure_WhenOptionEnabledAsync()
+ {
+ // Arrange
+ var expectedException = new InvalidOperationException("Service failed");
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ThrowsAsync(expectedException);
+
+ Mock mockContextProvider = new((object?)null, (object?)null, (object?)null);
+ mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
+ mockContextProvider
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask(new AIContext()));
+ mockContextProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ AIContextProviders = [mockContextProvider.Object],
+ PersistChatHistoryAfterEachServiceCall = true,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session));
+
+ // Assert — the decorator should have notified the AIContextProvider of the failure
+ mockContextProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.InvokeException != null &&
+ x.InvokeException.Message == "Service failed"),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that when PersistChatHistoryAfterEachServiceCall is enabled,
+ /// both ChatHistoryProvider and AIContextProviders are notified together.
+ ///
+ [Fact]
+ public async Task RunAsync_NotifiesBothProviders_WhenOptionEnabledAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask());
+
+ Mock mockContextProvider = new((object?)null, (object?)null, (object?)null);
+ mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
+ mockContextProvider
+ .Protected()
+ .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask(new AIContext()));
+ mockContextProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ AIContextProviders = [mockContextProvider.Object],
+ PersistChatHistoryAfterEachServiceCall = true,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+
+ // Assert — both providers should have been notified
+ mockChatHistoryProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.ResponseMessages != null &&
+ x.ResponseMessages.Any(m => m.Text == "response")),
+ ItExpr.IsAny());
+
+ mockContextProvider
+ .Protected()
+ .Verify("InvokedCoreAsync", Times.Once(),
+ ItExpr.Is(x =>
+ x.ResponseMessages != null &&
+ x.ResponseMessages.Any(m => m.Text == "response")),
+ ItExpr.IsAny());
+ }
+
+ ///
+ /// Verifies that during a FIC loop, response messages from the first call are not
+ /// re-notified as request messages on the second call.
+ ///
+ [Fact]
+ public async Task RunAsync_DoesNotReNotifyResponseMessagesAsRequestMessages_DuringFicLoopAsync()
+ {
+ // Arrange
+ int serviceCallCount = 0;
+ var assistantToolCallMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())]);
+
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(() =>
+ {
+ serviceCallCount++;
+ if (serviceCallCount == 1)
+ {
+ return Task.FromResult(new ChatResponse([assistantToolCallMessage]));
+ }
+
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
+ });
+
+ var invokedContexts = new List();
+
+ Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
+ .Returns(() => new ValueTask());
+
+ var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatOptions = new() { Tools = [tool] },
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAfterEachServiceCall = true,
+ }, services: new ServiceCollection().BuildServiceProvider());
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+
+ // Assert
+ Assert.Equal(2, invokedContexts.Count);
+
+ // The assistant tool call message was a response in call 1
+ Assert.Contains(invokedContexts[0].ResponseMessages!, m => ReferenceEquals(m, assistantToolCallMessage));
+
+ // It should NOT appear as a request in call 2 (it was already notified as a response)
+ var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
+ Assert.DoesNotContain(secondRequestMessages, m => ReferenceEquals(m, assistantToolCallMessage));
+ }
+
+ ///
+ /// Verifies that when a failure occurs on the second call in a FIC loop,
+ /// only new request messages (not previously notified) are sent in the failure notification.
+ ///
+ [Fact]
+ public async Task RunAsync_DeduplicatesRequestMessages_OnFailureDuringFicLoopAsync()
+ {
+ // Arrange
+ int serviceCallCount = 0;
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(() =>
+ {
+ serviceCallCount++;
+ if (serviceCallCount == 1)
+ {
+ return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())])]));
+ }
+
+ throw new InvalidOperationException("Service failure on second call");
+ });
+
+ var invokedContexts = new List();
+
+ Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
+ .Returns(() => new ValueTask());
+
+ var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatOptions = new() { Tools = [tool] },
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAfterEachServiceCall = true,
+ }, services: new ServiceCollection().BuildServiceProvider());
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await Assert.ThrowsAsync(() =>
+ agent.RunAsync([new(ChatRole.User, "test")], session));
+
+ // Assert — should have 2 notifications: success on call 1, failure on call 2
+ Assert.Equal(2, invokedContexts.Count);
+
+ // First notification: success, has user message as request
+ Assert.Null(invokedContexts[0].InvokeException);
+ Assert.Contains(invokedContexts[0].RequestMessages, m => m.Text == "test");
+
+ // Second notification: failure, should NOT include the user message (already notified)
+ Assert.NotNull(invokedContexts[1].InvokeException);
+ var failureRequestMessages = invokedContexts[1].RequestMessages.ToList();
+ Assert.DoesNotContain(failureRequestMessages, m => m.Text == "test");
+ }
+
+ ///
+ /// Verifies that the NotifiedMessages set on the session is properly cleaned up after
+ /// a successful run completes.
+ ///
+ [Fact]
+ public async Task RunAsync_CleansUpNotifiedMessages_AfterRunCompletesAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+
+ Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
+ mockChatHistoryProvider
+ .Protected()
+ .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
+ new ValueTask>(ctx.RequestMessages.ToList()));
+ mockChatHistoryProvider
+ .Protected()
+ .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Returns(() => new ValueTask());
+
+ ChatClientAgent agent = new(mockService.Object, options: new()
+ {
+ ChatHistoryProvider = mockChatHistoryProvider.Object,
+ PersistChatHistoryAfterEachServiceCall = true,
+ });
+
+ // Act
+ var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
+ await agent.RunAsync([new(ChatRole.User, "test")], session);
+
+ // Assert — NotifiedMessages should be empty (cleared) after the run completes
+ Assert.NotNull(session!.NotifiedMessages);
+ Assert.Empty(session.NotifiedMessages);
+ }
+
+ private static async IAsyncEnumerable CreateAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
+ {
+ foreach (var update in updates)
+ {
+ yield return update;
+ }
+
+ await Task.CompletedTask;
+ }
+}
From 6909191a13a32aa04ea3e5c7f2ca5f5999ffe048 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Fri, 13 Mar 2026 17:26:02 +0000
Subject: [PATCH 2/5] Revert version reset
---
dotnet/global.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dotnet/global.json b/dotnet/global.json
index 482aa6b8d3..42bb8863a3 100644
--- a/dotnet/global.json
+++ b/dotnet/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.100",
+ "version": "10.0.200",
"rollForward": "minor",
"allowPrerelease": false
},
From 65764197f8c31a106b4e7cda09c6590560105852 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Wed, 18 Mar 2026 11:30:08 +0000
Subject: [PATCH 3/5] Fix bugs and improve sample
---
.../Program.cs | 156 +++++++++++++-----
.../ChatClient/ChatClientAgent.cs | 25 ---
.../ChatClient/ChatClientAgentSession.cs | 14 --
.../ChatHistoryPersistingChatClient.cs | 105 ++++++++----
.../ChatHistoryPersistingChatClientTests.cs | 40 ++---
5 files changed, 214 insertions(+), 126 deletions(-)
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
index 3a16c6a8e7..333d92d838 100644
--- a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
+++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs
@@ -8,8 +8,8 @@
// intermediate messages (tool calls and results) are persisted after each service call,
// allowing you to inspect or recover them even if the process is interrupted mid-loop.
//
-// The sample uses RunStreamingAsync so that we can observe the chat history growing
-// after each service call within a single agent run.
+// The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one
+// using streaming (RunStreamingAsync), to demonstrate correct behavior in both modes.
using System.ComponentModel;
using Azure.AI.OpenAI;
@@ -34,6 +34,7 @@ static string GetWeather([Description("The city name.")] string city) =>
"SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
"NEW YORK" => "New York: 72°F, sunny and warm.",
"LONDON" => "London: 48°F, overcast with fog.",
+ "DUBLIN" => "Dublin: 43°F, overcast with fog.",
_ => $"{city}: weather data not available."
};
@@ -44,6 +45,7 @@ static string GetTime([Description("The city name.")] string city) =>
"SEATTLE" => "Seattle: 9:00 AM PST",
"NEW YORK" => "New York: 12:00 PM EST",
"LONDON" => "London: 5:00 PM GMT",
+ "DUBLIN" => "Dublin: 5:00 PM GMT",
_ => $"{city}: time data not available."
};
@@ -62,57 +64,135 @@ static string GetTime([Description("The city name.")] string city) =>
PersistChatHistoryAfterEachServiceCall = true,
});
-AgentSession session = await agent.CreateSessionAsync();
+await RunNonStreamingAsync();
+await RunStreamingAsync();
-// Ask about multiple cities — the model will need to call tools for each city,
-// resulting in multiple service calls within a single agent run.
-string prompt = "What's the weather and time in Seattle, New York, and London?";
+async Task RunNonStreamingAsync()
+{
+ int lastChatHistorySize = 0;
-Console.ForegroundColor = ConsoleColor.Cyan;
-Console.Write("\n[User] ");
-Console.ResetColor();
-Console.WriteLine(prompt);
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine("\n=== Non-Streaming Mode ===");
+ Console.ResetColor();
-PrintChatHistory("Before run");
+ AgentSession session = await agent.CreateSessionAsync();
-Console.ForegroundColor = ConsoleColor.Cyan;
-Console.Write("\n[Agent] ");
-Console.ResetColor();
+ // First turn — ask about multiple cities so the model calls tools.
+ const string Prompt = "What's the weather and time in Seattle, New York, and London?";
+ PrintUserMessage(Prompt);
-// Use RunStreamingAsync to observe the response as it streams.
-await foreach (var update in agent.RunStreamingAsync(prompt, session))
-{
- Console.Write(update);
-}
+ var response = await agent.RunAsync(Prompt, session);
+ PrintAgentResponse(response.Text);
+ PrintChatHistory(session, "After run", ref lastChatHistorySize);
-Console.WriteLine();
+ // Second turn — follow-up to verify chat history is correct.
+ const string FollowUp1 = "And Dublin?";
+ PrintUserMessage(FollowUp1);
-PrintChatHistory("After run");
+ response = await agent.RunAsync(FollowUp1, session);
+ PrintAgentResponse(response.Text);
+ PrintChatHistory(session, "After second run", ref lastChatHistorySize);
-// Run a second turn to show that chat history accumulated correctly.
-string followUp = "Which city is the warmest?";
-Console.ForegroundColor = ConsoleColor.Cyan;
-Console.Write("\n[User] ");
-Console.ResetColor();
-Console.WriteLine(followUp);
+ // Third turn — follow-up to verify chat history is correct.
+ const string FollowUp2 = "Which city is the warmest?";
+ PrintUserMessage(FollowUp2);
-Console.ForegroundColor = ConsoleColor.Cyan;
-Console.Write("\n[Agent] ");
-Console.ResetColor();
+ response = await agent.RunAsync(FollowUp2, session);
+ PrintAgentResponse(response.Text);
+ PrintChatHistory(session, "After third run", ref lastChatHistorySize);
+}
-await foreach (var update in agent.RunStreamingAsync(followUp, session))
+async Task RunStreamingAsync()
{
- Console.Write(update);
+ int lastChatHistorySize = 0;
+
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine("\n=== Streaming Mode ===");
+ Console.ResetColor();
+
+ AgentSession session = await agent.CreateSessionAsync();
+
+ // First turn — ask about multiple cities so the model calls tools.
+ const string Prompt = "What's the weather and time in Seattle, New York, and London?";
+ PrintUserMessage(Prompt);
+
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+
+ await foreach (var update in agent.RunStreamingAsync(Prompt, session))
+ {
+ Console.Write(update);
+
+ // During streaming we should be able to see updates to the chat history
+ // before the full run completes, as each service call is made and persisted.
+ PrintChatHistory(session, "During run", ref lastChatHistorySize);
+ }
+
+ Console.WriteLine();
+ PrintChatHistory(session, "After run", ref lastChatHistorySize);
+
+ // Second turn — follow-up to verify chat history is correct.
+ const string FollowUp1 = "And Dublin?";
+ PrintUserMessage(FollowUp1);
+
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+
+ await foreach (var update in agent.RunStreamingAsync(FollowUp1, session))
+ {
+ Console.Write(update);
+
+ // During streaming we should be able to see updates to the chat history
+ // before the full run completes, as each service call is made and persisted.
+ PrintChatHistory(session, "During second run", ref lastChatHistorySize);
+ }
+
+ Console.WriteLine();
+ PrintChatHistory(session, "After second run", ref lastChatHistorySize);
+
+ // Third turn — follow-up to verify chat history is correct.
+ const string FollowUp2 = "Which city is the warmest?";
+ PrintUserMessage(FollowUp2);
+
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+
+ await foreach (var update in agent.RunStreamingAsync(FollowUp2, session))
+ {
+ Console.Write(update);
+
+ // During streaming we should be able to see updates to the chat history
+ // before the full run completes, as each service call is made and persisted.
+ PrintChatHistory(session, "During third run", ref lastChatHistorySize);
+ }
+
+ Console.WriteLine();
+ PrintChatHistory(session, "After third run", ref lastChatHistorySize);
}
-Console.WriteLine();
+void PrintUserMessage(string message)
+{
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[User] ");
+ Console.ResetColor();
+ Console.WriteLine(message);
+}
-PrintChatHistory("After second run");
+void PrintAgentResponse(string? text)
+{
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+ Console.WriteLine(text);
+}
// Helper to print the current chat history from the session.
-void PrintChatHistory(string label)
+void PrintChatHistory(AgentSession session, string label, ref int lastChatHistorySize)
{
- if (session.TryGetInMemoryChatHistory(out var history))
+ if (session.TryGetInMemoryChatHistory(out var history) && history.Count != lastChatHistorySize)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"\n [{label} — Chat history: {history.Count} message(s)]");
@@ -120,9 +200,11 @@ void PrintChatHistory(string label)
{
var preview = msg.Text?.Length > 80 ? msg.Text[..80] + "…" : msg.Text;
var contentTypes = string.Join(", ", msg.Contents.Select(c => c.GetType().Name));
- Console.WriteLine($" {msg.Role,-12} | {preview ?? $"[{contentTypes}]"}");
+ Console.WriteLine($" {msg.Role,-12} | {(string.IsNullOrWhiteSpace(preview) ? $"[{contentTypes}]" : preview)}");
}
Console.ResetColor();
+
+ lastChatHistorySize = history.Count;
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
index bba8c474ef..a3f49810b8 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
@@ -217,13 +217,6 @@ protected override async Task RunCoreAsync(
var loggingAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType);
- // Initialize the per-service-call message tracking if the decorator is being used.
- if (this.PersistsChatHistoryPerServiceCall)
- {
- safeSession.NotifiedMessages ??= new();
- safeSession.NotifiedMessages.Clear();
- }
-
// Call the IChatClient and notify the AIContextProvider of any failures.
ChatResponse chatResponse;
try
@@ -235,11 +228,6 @@ protected override async Task RunCoreAsync(
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
- finally
- {
- // Clear the per-service-call message tracking now that the run is complete (or failed).
- safeSession.NotifiedMessages?.Clear();
- }
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType, inputMessages.Count);
@@ -310,13 +298,6 @@ protected override async IAsyncEnumerable RunCoreStreamingA
this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
- // Initialize the per-service-call message tracking if the decorator is being used.
- if (this.PersistsChatHistoryPerServiceCall)
- {
- safeSession.NotifiedMessages ??= new();
- safeSession.NotifiedMessages.Clear();
- }
-
List responseUpdates = GetResponseUpdates(continuationToken);
IAsyncEnumerator responseUpdatesEnumerator;
@@ -328,7 +309,6 @@ protected override async IAsyncEnumerable RunCoreStreamingA
}
catch (Exception ex)
{
- safeSession.NotifiedMessages?.Clear();
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -343,7 +323,6 @@ protected override async IAsyncEnumerable RunCoreStreamingA
}
catch (Exception ex)
{
- safeSession.NotifiedMessages?.Clear();
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -370,7 +349,6 @@ protected override async IAsyncEnumerable RunCoreStreamingA
}
catch (Exception ex)
{
- safeSession.NotifiedMessages?.Clear();
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -384,9 +362,6 @@ protected override async IAsyncEnumerable RunCoreStreamingA
// Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
-
- // Clear the per-service-call message tracking now that the run is complete.
- safeSession.NotifiedMessages?.Clear();
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs
index f7093472e9..574d54700c 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentSession.cs
@@ -90,18 +90,4 @@ internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = nu
private string DebuggerDisplay =>
this.ConversationId is { } conversationId ? $"ConversationId = {conversationId}, StateBag Count = {this.StateBag.Count}" :
$"StateBag Count = {this.StateBag.Count}";
-
- ///
- /// Gets or sets the set of instances that have already been notified to providers
- /// during the current agent run. Used by to avoid duplicate
- /// notifications when loops cause the same messages to be passed
- /// across multiple service calls.
- ///
- ///
- /// This set is cleared at the start and end of each run. It uses reference equality
- /// to track message identity since reuses the same message objects
- /// across loop iterations.
- ///
- [JsonIgnore]
- internal HashSet? NotifiedMessages { get; set; }
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
index 0b4d02c95f..733e0458c4 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
@@ -32,6 +32,12 @@ namespace Microsoft.Agents.AI;
///
internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
{
+ ///
+ /// The key used in and
+ /// to mark messages and their content as already persisted to chat history.
+ ///
+ internal const string PersistedMarkerKey = "_chatHistoryPersisted";
+
///
/// Initializes a new instance of the class.
///
@@ -56,16 +62,15 @@ public override async Task GetResponseAsync(
}
catch (Exception ex)
{
- var newRequestMessagesOnFailure = GetNewMessages(messages, session);
- MarkAsNotified(newRequestMessagesOnFailure, session);
+ var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
throw;
}
- var newRequestMessages = GetNewMessages(messages, session);
- MarkAsNotified(newRequestMessages, session);
- MarkAsNotified(response.Messages, session);
+ var newRequestMessages = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, response.Messages, options, cancellationToken).ConfigureAwait(false);
+ MarkAsPersisted(newRequestMessages);
+ MarkAsPersisted(response.Messages);
return response;
}
@@ -87,8 +92,7 @@ public override async IAsyncEnumerable GetStreamingResponseA
}
catch (Exception ex)
{
- var newRequestMessagesOnFailure = GetNewMessages(messages, session);
- MarkAsNotified(newRequestMessagesOnFailure, session);
+ var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -100,8 +104,7 @@ public override async IAsyncEnumerable GetStreamingResponseA
}
catch (Exception ex)
{
- var newRequestMessagesOnFailure = GetNewMessages(messages, session);
- MarkAsNotified(newRequestMessagesOnFailure, session);
+ var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -118,18 +121,17 @@ public override async IAsyncEnumerable GetStreamingResponseA
}
catch (Exception ex)
{
- var newRequestMessagesOnFailure = GetNewMessages(messages, session);
- MarkAsNotified(newRequestMessagesOnFailure, session);
+ var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
- var newRequestMessages = GetNewMessages(messages, session);
- MarkAsNotified(newRequestMessages, session);
- MarkAsNotified(chatResponse.Messages, session);
+ var newRequestMessages = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, chatResponse.Messages, options, cancellationToken).ConfigureAwait(false);
+ MarkAsPersisted(newRequestMessages);
+ MarkAsPersisted(chatResponse.Messages);
}
///
@@ -160,35 +162,76 @@ private static (ChatClientAgent Agent, ChatClientAgentSession Session) GetRequir
}
///
- /// Filters the given messages to return only those that have not yet been notified to providers
- /// during the current agent run.
+ /// Returns only the request messages that have not yet been persisted to chat history.
+ ///
+ ///
+ /// A message is considered already persisted if any of the following is true:
+ ///
+ /// - It has the in its .
+ /// - It has an of
+ /// (indicating it was loaded from chat history and does not need to be re-persisted).
+ /// - It has and all of its items have the
+ /// in their . This handles the
+ /// streaming case where reconstructs objects
+ /// independently via ToChatResponse(), producing different object references that share the same
+ /// underlying instances.
+ ///
+ ///
+ /// A list of request messages that have not yet been persisted.
+ /// The full set of request messages to filter.
+ private static List GetNewRequestMessages(IEnumerable messages)
+ {
+ return messages.Where(m => !IsAlreadyPersisted(m)).ToList();
+ }
+
+ ///
+ /// Determines whether a message has already been persisted to chat history by this decorator.
///
- /// The full set of messages to filter.
- /// The current session containing the set of already-notified messages.
- /// A list of messages that have not yet been notified. If no tracking is available, all messages are returned.
- private static IReadOnlyList GetNewMessages(IEnumerable messages, ChatClientAgentSession session)
+ private static bool IsAlreadyPersisted(ChatMessage message)
{
- HashSet? notifiedMessages = session.NotifiedMessages;
- if (notifiedMessages is null or { Count: 0 })
+ if (message.AdditionalProperties?.TryGetValue(PersistedMarkerKey, out var value) == true && value is true)
{
- return messages as IReadOnlyList ?? messages.ToList();
+ return true;
}
- return messages.Where(m => !notifiedMessages.Contains(m)).ToList();
+ if (message.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.ChatHistory)
+ {
+ return true;
+ }
+
+ // In streaming mode, FunctionInvokingChatClient reconstructs ChatMessage objects via ToChatResponse()
+ // independently, producing different ChatMessage instances. However, the underlying AIContent objects
+ // (e.g., FunctionCallContent, FunctionResultContent) are shared references. Checking for markers on
+ // AIContent handles dedup in this case.
+ if (message.Contents.Count > 0 && message.Contents.All(c => c.AdditionalProperties?.TryGetValue(PersistedMarkerKey, out var value) == true && value is true))
+ {
+ return true;
+ }
+
+ return false;
}
///
- /// Marks the given messages as notified so they will be excluded from future notifications in the current run.
+ /// Marks the given messages as persisted by setting a marker on both the
+ /// and each of its items.
///
- /// The messages to mark as notified.
- /// The current session containing the set of already-notified messages.
- private static void MarkAsNotified(IEnumerable messages, ChatClientAgentSession session)
+ ///
+ /// Both levels are marked because may reconstruct
+ /// objects in streaming mode (losing the message-level marker),
+ /// but the references are shared and retain their markers.
+ ///
+ /// The messages to mark as persisted.
+ private static void MarkAsPersisted(IEnumerable messages)
{
- if (session.NotifiedMessages is { } notifiedMessages)
+ foreach (var message in messages)
{
- foreach (var message in messages)
+ message.AdditionalProperties ??= new();
+ message.AdditionalProperties[PersistedMarkerKey] = true;
+
+ foreach (var content in message.Contents)
{
- notifiedMessages.Add(message);
+ content.AdditionalProperties ??= new();
+ content.AdditionalProperties[PersistedMarkerKey] = true;
}
}
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
index 54bd42910e..717e38f549 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
@@ -35,7 +35,7 @@ public async Task RunAsync_PersistsMessagesPerServiceCall_WhenOptionEnabledAsync
It.IsAny(),
It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
- Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
@@ -82,7 +82,7 @@ public async Task RunAsync_PersistsMessagesAtEndOfRun_WhenOptionDisabledAsync()
It.IsAny(),
It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
- Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
@@ -130,7 +130,7 @@ public async Task RunAsync_NotifiesProviderOfFailure_WhenOptionEnabledAndService
It.IsAny(),
It.IsAny())).ThrowsAsync(expectedException);
- Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
@@ -275,7 +275,7 @@ public async Task RunAsync_PersistsPerServiceCall_DuringFunctionInvocationLoopAs
var invokedContexts = new List();
- Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
@@ -349,7 +349,7 @@ public async Task RunStreamingAsync_PersistsMessagesPerServiceCall_WhenOptionEna
new ChatResponseUpdate(ChatRole.Assistant, "streaming "),
new ChatResponseUpdate(ChatRole.Assistant, "response")));
- Mock mockChatHistoryProvider = new((object?)null, (object?)null, (object?)null);
+ Mock