Merge branch 'main' into dev/dotnet_workflow/remove_timeout

This commit is contained in:
Jacob Alber
2026-03-31 16:55:07 -04:00
committed by GitHub
Unverified
177 changed files with 2285 additions and 18954 deletions
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how the ChatClientAgent persists chat history after each individual
// call to the AI service, using the SimulateServiceStoredChatHistory option.
// call to the AI service, using the RequirePerServiceCallChatHistoryPersistence option.
// When an agent uses tools, FunctionInvokingChatClient may loop multiple times
// (service call → tool execution → service call), and intermediate messages (tool calls and
// results) are persisted after each service call. This allows you to inspect or recover them
@@ -9,7 +9,7 @@
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
//
// To use end-of-run persistence instead (atomic run semantics), remove the
// SimulateServiceStoredChatHistory = true setting (or set it to false). End-of-run
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
// persistence is the default behavior.
//
// The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one
@@ -54,7 +54,7 @@ static string GetTime([Description("The city name.")] string city) =>
_ => $"{city}: time data not available."
};
// Create the agent — per-service-call persistence is enabled via SimulateServiceStoredChatHistory.
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
IChatClient chatClient = string.Equals(store, "TRUE", StringComparison.OrdinalIgnoreCase) ?
@@ -64,7 +64,7 @@ AIAgent agent = chatClient.AsAIAgent(
new ChatClientAgentOptions
{
Name = "WeatherAssistant",
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
ChatOptions = new()
{
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
@@ -1,19 +1,19 @@
# In-Function-Loop Checkpointing
This sample demonstrates how `ChatClientAgent` can persist chat history after each individual call to the AI service using the `SimulateServiceStoredChatHistory` option. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
This sample demonstrates how `ChatClientAgent` can persist chat history after each individual call to the AI service using the `RequirePerServiceCallChatHistoryPersistence` option. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
## What This Sample Shows
When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By enabling `SimulateServiceStoredChatHistory = true`, chat history is persisted after each service call via the `ServiceStoredSimulatingChatClient` decorator:
When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By enabling `RequirePerServiceCallChatHistoryPersistence = true`, chat history is persisted after each service call via the `PerServiceCallChatHistoryPersistingChatClient` decorator:
- A `ServiceStoredSimulatingChatClient` decorator is inserted into the chat client pipeline
- A `PerServiceCallChatHistoryPersistingChatClient` decorator is inserted into the chat client pipeline
- Before each service call, the decorator loads history from the `ChatHistoryProvider` and prepends it to the request
- 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
By default (without `SimulateServiceStoredChatHistory`), chat history is persisted at the end of the full agent run instead. To use per-service-call persistence, set `SimulateServiceStoredChatHistory = true` on `ChatClientAgentOptions`.
By default (without `RequirePerServiceCallChatHistoryPersistence`), chat history is persisted at the end of the full agent run instead. To use per-service-call persistence, set `RequirePerServiceCallChatHistoryPersistence = true` on `ChatClientAgentOptions`.
With `SimulateServiceStoredChatHistory` = true, the behavior matches that of chat history stored in the underlying AI service exactly.
With `RequirePerServiceCallChatHistoryPersistence` = true, the behavior matches that of chat history stored in the underlying AI service exactly.
Per-service-call persistence is useful for:
- **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted
@@ -29,7 +29,7 @@ The sample asks the agent about the weather and time in three cities. The model
```
ChatClientAgent
└─ FunctionInvokingChatClient (handles tool call loop)
└─ ServiceStoredSimulatingChatClient (persists after each service call)
└─ PerServiceCallChatHistoryPersistingChatClient (persists after each service call)
└─ Leaf IChatClient (Azure OpenAI)
```
+1 -1
View File
@@ -3,7 +3,7 @@
The agent framework samples are designed to help you get started with building AI-powered agents
from various providers.
The Agent Framework supports building agents using various infererence and inference-style services.
The Agent Framework supports building agents using various inference and inference-style services.
All these are supported using the single `ChatClientAgent` class.
The Agent Framework also supports creating proxy agents, that allow accessing remote agents as if they
@@ -139,8 +139,8 @@ public sealed partial class ChatClientAgent : AIAgent
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
// Warn if using a custom chat client stack with simulated service stored persistence but no ServiceStoredSimulatingChatClient.
this.WarnOnMissingServiceStoredSimulatingClient();
// Warn if using a custom chat client stack with simulated service stored persistence but no PerServiceCallChatHistoryPersistingChatClient.
this.WarnOnMissingPerServiceCallChatHistoryPersistingChatClient();
}
/// <summary>
@@ -454,7 +454,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of successfully completed messages.
/// </summary>
/// <remarks>
/// This method is also called by <see cref="ServiceStoredSimulatingChatClient"/> to persist messages per-service-call.
/// This method is also called by <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to persist messages per-service-call.
/// </remarks>
internal async Task NotifyProvidersOfNewMessagesAsync(
ChatClientAgentSession session,
@@ -486,7 +486,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of a failure during a service call.
/// </summary>
/// <remarks>
/// This method is also called by <see cref="ServiceStoredSimulatingChatClient"/> to report failures per-service-call.
/// This method is also called by <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to report failures per-service-call.
/// </remarks>
internal async Task NotifyProvidersOfFailureAsync(
ChatClientAgentSession session,
@@ -701,7 +701,7 @@ public sealed partial class ChatClientAgent : AIAgent
throw new InvalidOperationException("A session must be provided when continuing a background response with a continuation token.");
}
if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.SimulatesServiceStoredChatHistory && this._logger.IsEnabled(LogLevel.Warning))
if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.RequiresPerServiceCallChatHistoryPersistence && this._logger.IsEnabled(LogLevel.Warning))
{
var warningAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientBackgroundResponseFallback(this.Id, warningAgentName);
@@ -740,10 +740,10 @@ public sealed partial class ChatClientAgent : AIAgent
IEnumerable<ChatMessage> inputMessagesForChatClient = inputMessages;
// Populate the session messages only if we are not continuing an existing response as it's not allowed.
// When SimulateServiceStoredChatHistory is active, the ServiceStoredSimulatingChatClient
// When RequirePerServiceCallChatHistoryPersistence is active, the PerServiceCallChatHistoryPersistingChatClient
// owns the chat history lifecycle — it loads history before each service call. The agent
// must not load history itself, as that would result in duplicate messages.
if (chatOptions?.ContinuationToken is null && !this.SimulatesServiceStoredChatHistory)
if (chatOptions?.ContinuationToken is null && !this.RequiresPerServiceCallChatHistoryPersistence)
{
// Add any existing messages from the session to the messages to be sent to the chat client.
// The ChatHistoryProvider returns the merged result (history + input messages).
@@ -837,14 +837,14 @@ public sealed partial class ChatClientAgent : AIAgent
/// Updates the session conversation ID at the end of an agent run.
/// </summary>
/// <remarks>
/// When a <see cref="ServiceStoredSimulatingChatClient"/> handles per-service-call
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
/// conversation ID updates, this end-of-run update is skipped. When the decorator is
/// absent, the update is performed here. When <paramref name="forceUpdate"/> is <see langword="true"/>
/// (continuation token scenarios), the update is always performed.
/// </remarks>
private void UpdateSessionConversationIdAtEndOfRun(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken, bool forceUpdate = false)
{
if (!forceUpdate && this.SimulatesServiceStoredChatHistory)
if (!forceUpdate && this.RequiresPerServiceCallChatHistoryPersistence)
{
return;
}
@@ -856,7 +856,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies providers of successfully completed messages at the end of an agent run.
/// </summary>
/// <remarks>
/// When a <see cref="ServiceStoredSimulatingChatClient"/> handles per-service-call
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
/// notification, this end-of-run notification is skipped. When no decorator is present,
/// all messages are persisted.
/// When <paramref name="forceNotify"/> is <see langword="true"/> (continuation token or
@@ -871,7 +871,7 @@ public sealed partial class ChatClientAgent : AIAgent
CancellationToken cancellationToken,
bool forceNotify = false)
{
if (!forceNotify && this.SimulatesServiceStoredChatHistory)
if (!forceNotify && this.RequiresPerServiceCallChatHistoryPersistence)
{
return Task.CompletedTask;
}
@@ -883,7 +883,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies providers of a failure at the end of an agent run.
/// </summary>
/// <remarks>
/// When a <see cref="ServiceStoredSimulatingChatClient"/> handles per-service-call
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
/// notification (including failure), this end-of-run notification is skipped to avoid
/// duplicate notification. In all other cases, failure is reported at the end of the run.
/// </remarks>
@@ -894,7 +894,7 @@ public sealed partial class ChatClientAgent : AIAgent
ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
if (this.SimulatesServiceStoredChatHistory)
if (this.RequiresPerServiceCallChatHistoryPersistence)
{
return Task.CompletedTask;
}
@@ -905,14 +905,14 @@ public sealed partial class ChatClientAgent : AIAgent
/// <summary>
/// Gets a value indicating whether the agent is configured to simulate service-stored chat history.
/// When <see langword="true"/>, end-of-run persistence and history loading are skipped because a
/// per-service-call decorator (such as <see cref="ServiceStoredSimulatingChatClient"/> or a
/// per-service-call decorator (such as <see cref="PerServiceCallChatHistoryPersistingChatClient"/> or a
/// user-supplied equivalent) is expected to handle the history lifecycle.
/// </summary>
private bool SimulatesServiceStoredChatHistory
private bool RequiresPerServiceCallChatHistoryPersistence
{
get
{
return this._agentOptions?.SimulateServiceStoredChatHistory is true;
return this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is true;
}
}
@@ -923,7 +923,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// The base class sets <see cref="AIAgent.CurrentRunContext"/> with the raw session parameter
/// (which may be null) and restores it after each yield in streaming scenarios. After
/// <see cref="PrepareSessionAndMessagesAsync"/> resolves or creates a session, we update the
/// context so the <see cref="ServiceStoredSimulatingChatClient"/> decorator always has a valid session.
/// context so the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator always has a valid session.
/// The original agent from the context is preserved to maintain the top-of-stack agent in
/// decorated agent scenarios.
/// </remarks>
@@ -939,19 +939,19 @@ public sealed partial class ChatClientAgent : AIAgent
/// <summary>
/// Checks for potential misconfiguration when using a custom chat client stack and logs warnings.
/// </summary>
private void WarnOnMissingServiceStoredSimulatingClient()
private void WarnOnMissingPerServiceCallChatHistoryPersistingChatClient()
{
if (this._agentOptions?.UseProvidedChatClientAsIs is not true)
{
return;
}
if (this._agentOptions?.SimulateServiceStoredChatHistory is not true)
if (this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is not true)
{
return;
}
var persistingClient = this.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var persistingClient = this.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
if (persistingClient is null && this._logger.IsEnabled(LogLevel.Warning))
{
var loggingAgentName = this.GetLoggingAgentName();
@@ -998,7 +998,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// </summary>
/// <remarks>
/// This method is used by both the agent (during <see cref="PrepareSessionAndMessagesAsync"/>) and by
/// <see cref="ServiceStoredSimulatingChatClient"/> to load history before each service call.
/// <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to load history before each service call.
/// </remarks>
internal async Task<IEnumerable<ChatMessage>> LoadChatHistoryAsync(
ChatClientAgentSession session,
@@ -72,12 +72,12 @@ internal static partial class ChatClientAgentLogMessages
/// <summary>
/// Logs a warning when <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>
/// and <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> is <see langword="true"/>,
/// but no <see cref="ServiceStoredSimulatingChatClient"/> is found in the custom chat client stack.
/// and <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>,
/// but no <see cref="PerServiceCallChatHistoryPersistingChatClient"/> is found in the custom chat client stack.
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Agent {AgentId}/{AgentName}: SimulateServiceStoredChatHistory is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no ServiceStoredSimulatingChatClient was found in the pipeline. Chat history will not be persisted by ChatClientAgent. Consider adding a ServiceStoredSimulatingChatClient to the pipeline using the UseServiceStoredChatHistorySimulation extension method if you have not added your own persistence mechanism.")]
Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no PerServiceCallChatHistoryPersistingChatClient was found in the pipeline. Chat history will not be persisted by ChatClientAgent. Consider adding a PerServiceCallChatHistoryPersistingChatClient to the pipeline using the UsePerServiceCallChatHistoryPersistence extension method if you have not added your own persistence mechanism.")]
public static partial void LogAgentChatClientMissingPersistingClient(
this ILogger logger,
string agentId,
@@ -92,7 +92,7 @@ internal static partial class ChatClientAgentLogMessages
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Agent {AgentId}/{AgentName}: SimulateServiceStoredChatHistory is enabled but we have to fall back to end-of-run persistence because the run involves background responses.")]
Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled but we have to fall back to end-of-run persistence because the run involves background responses.")]
public static partial void LogAgentChatClientBackgroundResponseFallback(
this ILogger logger,
string agentId,
@@ -92,38 +92,56 @@ public sealed class ChatClientAgentOptions
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether the <see cref="ChatClientAgent"/> should simulate
/// service-stored chat history behavior using its configured <see cref="ChatHistoryProvider"/>.
/// Gets or sets a value indicating whether the <see cref="ChatClientAgent"/> should persist
/// chat history after each individual service call within the <see cref="FunctionInvokingChatClient"/>
/// loop, rather than at the end of the full agent run.
/// </summary>
/// <remarks>
/// <para>
/// When set to <see langword="true"/>, a <see cref="ServiceStoredSimulatingChatClient"/> decorator is
/// injected between the <see cref="FunctionInvokingChatClient"/> and the leaf <see cref="IChatClient"/>
/// in the chat client pipeline. This decorator takes full ownership of the chat history lifecycle:
/// it loads history from the <see cref="ChatHistoryProvider"/> before each service call and persists
/// new messages after each service call. It also returns a sentinel <see cref="ChatOptions.ConversationId"/>
/// on the response, causing the <see cref="FunctionInvokingChatClient"/> to treat the conversation
/// as service-managed — clearing accumulated history and not injecting duplicate
/// <see cref="FunctionCallContent"/> during approval-response processing.
/// </para>
/// <para>
/// This mode aligns the behavior of framework-managed chat history with service-stored chat history,
/// ensuring consistency in how messages are stored and loaded, including during function calling loops
/// and tool-call termination scenarios.
/// When set to <see langword="true"/>, a <see cref="PerServiceCallChatHistoryPersistingChatClient"/>
/// decorator becomes active in the chat client pipeline. It handles two complementary scenarios:
/// </para>
/// <list type="bullet">
/// <item>
/// <term>Framework-managed chat history</term>
/// <description>
/// The decorator loads history from the <see cref="ChatHistoryProvider"/> before each service call
/// and persists new request and response messages after each call. It returns a sentinel
/// <see cref="ChatOptions.ConversationId"/> on the response, causing the
/// <see cref="FunctionInvokingChatClient"/> to treat the conversation as service-managed — clearing
/// accumulated history between iterations and not injecting duplicate <see cref="FunctionCallContent"/>
/// during approval-response processing.
/// </description>
/// </item>
/// <item>
/// <term>AI Service-stored chat history</term>
/// <description>
/// When the service manages its own chat history (returning a real <see cref="ChatOptions.ConversationId"/>),
/// the decorator updates <see cref="ChatClientAgentSession.ConversationId"/> after each service call so
/// that intermediate ConversationId changes are captured immediately. For some services (e.g., the
/// Conversations API with the Responses API), there is only one thread with one ID, so every service
/// call updates it anyway and updating the <see cref="ChatClientAgentSession.ConversationId"/> has little effect
/// since it's the same ID. For other services (e.g., Responses API with Response IDs), a new ID is generated
/// with each service call, so updating the <see cref="ChatClientAgentSession.ConversationId"/> ensures that the
/// latest ID is always captured, even mid-run.
/// Enabling this option ensures consistent per-service-call behavior across all service types.
/// </description>
/// </item>
/// </list>
/// <para>
/// When set to <see langword="false"/> (the default), the <see cref="ChatClientAgent"/> handles
/// chat history persistence at the end of the full agent run via the <see cref="ChatHistoryProvider"/>
/// pipeline.
/// chat history persistence at the end of the full agent run via the <see cref="ChatHistoryProvider"/> if using
/// framework-managed chat history. For AI service-stored chat history, the <see cref="ChatClientAgentSession.ConversationId"/>
/// updates happen only at the end of the run.
/// </para>
/// <para>
/// When setting the <see cref="UseProvidedChatClientAsIs"/> setting to <see langword="true"/> and
/// <see cref="SimulateServiceStoredChatHistory"/> to <see langword="true"/>, ensure that your custom chat client stack includes a
/// <see cref="ServiceStoredSimulatingChatClient"/> to enable per-service-call persistence.
/// If no <see cref="ServiceStoredSimulatingChatClient"/> is provided, and you are not storing chat history via other means,
/// <see cref="RequirePerServiceCallChatHistoryPersistence"/> to <see langword="true"/>, ensure that your custom chat client stack includes a
/// <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to enable per-service-call persistence.
/// If no <see cref="PerServiceCallChatHistoryPersistingChatClient"/> is provided, and you are not storing chat history via other means,
/// no chat history may be stored.
/// When using a custom chat client stack, you can add a <see cref="ServiceStoredSimulatingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation"/>
/// When using a custom chat client stack, you can add a <see cref="PerServiceCallChatHistoryPersistingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UsePerServiceCallChatHistoryPersistence"/>
/// extension method.
/// </para>
/// </remarks>
@@ -131,7 +149,7 @@ public sealed class ChatClientAgentOptions
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool SimulateServiceStoredChatHistory { get; set; }
public bool RequirePerServiceCallChatHistoryPersistence { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
@@ -149,6 +167,6 @@ public sealed class ChatClientAgentOptions
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
SimulateServiceStoredChatHistory = this.SimulateServiceStoredChatHistory,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
};
}
@@ -86,21 +86,21 @@ public static class ChatClientBuilderExtensions
services: services);
/// <summary>
/// Adds a <see cref="ServiceStoredSimulatingChatClient"/> to the chat client pipeline.
/// Adds a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned between the <see cref="FunctionInvokingChatClient"/> and the leaf
/// <see cref="IChatClient"/> in the pipeline. It simulates service-stored chat history behavior by
/// loading history before each service call, persisting after each call, and returning a sentinel
/// <see cref="ChatOptions.ConversationId"/> on the response.
/// <see cref="IChatClient"/> in the pipeline. It persists chat history after each individual service call
/// and updates the session <see cref="ChatOptions.ConversationId"/> per call for both framework-managed
/// and service-stored chat history scenarios.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
/// <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> is <see langword="true"/>.
/// the <see cref="ChatClientAgent"/> automatically includes this decorator in the pipeline and activates it when
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
@@ -110,8 +110,8 @@ public static class ChatClientBuilderExtensions
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseServiceStoredChatHistorySimulation(this ChatClientBuilder builder)
public static ChatClientBuilder UsePerServiceCallChatHistoryPersistence(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new ServiceStoredSimulatingChatClient(innerClient));
return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
}
@@ -63,16 +63,16 @@ public static class ChatClientExtensions
});
}
// ServiceStoredSimulatingChatClient is only injected when SimulateServiceStoredChatHistory is enabled.
// PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled.
// It 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 → ServiceStoredSimulatingChatClient → leaf IChatClient
// FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient
// This allows the decorator to simulate service-stored chat history by loading history before
// each service call, persisting after each call, and returning a sentinel ConversationId.
if (options?.SimulateServiceStoredChatHistory is true)
if (options?.RequirePerServiceCallChatHistoryPersistence is true)
{
chatBuilder.Use(innerClient => new ServiceStoredSimulatingChatClient(innerClient));
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
var agentChatClient = chatBuilder.Build(services);
@@ -11,23 +11,39 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that simulates service-stored chat history behavior using
/// framework-managed <see cref="ChatHistoryProvider"/> instances.
/// A delegating chat client that persists chat history and updates session state after each
/// individual service call within the <see cref="FunctionInvokingChatClient"/> loop.
/// </summary>
/// <remarks>
/// <para>
/// This decorator is intended to operate between the <see cref="FunctionInvokingChatClient"/> and the leaf
/// <see cref="IChatClient"/> in a <see cref="ChatClientAgent"/> pipeline.
/// <see cref="IChatClient"/> in a <see cref="ChatClientAgent"/> pipeline. It is activated when
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
/// </para>
/// <para>
/// Before each service call, it loads chat history from the agent's <see cref="ChatHistoryProvider"/>
/// and prepends it to the request messages. After each successful service call, it persists
/// new request and response messages to the provider. It also returns a sentinel
/// <see cref="ChatOptions.ConversationId"/> on the response so that the
/// <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed —
/// clearing accumulated history between iterations and not injecting duplicate
/// <see cref="FunctionCallContent"/> during approval-response processing.
/// When active, it handles two complementary scenarios:
/// </para>
/// <list type="bullet">
/// <item>
/// <term>Framework-managed chat history</term>
/// <description>
/// Before each service call, the decorator loads history from the agent's <see cref="ChatHistoryProvider"/>
/// and prepends it to the request messages. After each successful call, it persists new messages to
/// the provider and returns a sentinel <see cref="ChatOptions.ConversationId"/> so that
/// <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed — clearing
/// accumulated history between iterations and not injecting duplicate <see cref="FunctionCallContent"/>
/// during approval-response processing.
/// </description>
/// </item>
/// <item>
/// <term>Service-stored chat history</term>
/// <description>
/// When the underlying service manages its own chat history (real <see cref="ChatOptions.ConversationId"/>),
/// the decorator updates <see cref="ChatClientAgentSession.ConversationId"/> after each service call so
/// that intermediate ConversationId changes are captured immediately rather than only at the end of the run.
/// </description>
/// </item>
/// </list>
/// <para>
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
/// current agent and session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
@@ -38,7 +54,7 @@ namespace Microsoft.Agents.AI;
/// available or if the agent is not a <see cref="ChatClientAgent"/>.
/// </para>
/// </remarks>
internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient
internal sealed class PerServiceCallChatHistoryPersistingChatClient : DelegatingChatClient
{
/// <summary>
/// A sentinel value returned on <see cref="ChatResponse.ConversationId"/> to signal
@@ -59,10 +75,10 @@ internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient
internal const string LocalHistoryConversationId = "_agent_local_chat_history";
/// <summary>
/// Initializes a new instance of the <see cref="ServiceStoredSimulatingChatClient"/> class.
/// Initializes a new instance of the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
public ServiceStoredSimulatingChatClient(IChatClient innerClient)
public PerServiceCallChatHistoryPersistingChatClient(IChatClient innerClient)
: base(innerClient)
{
}
@@ -237,18 +253,18 @@ internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient
{
var runContext = AIAgent.CurrentRunContext
?? throw new InvalidOperationException(
$"{nameof(ServiceStoredSimulatingChatClient)} can only be used within the context of a running AIAgent. " +
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} 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.");
var chatClientAgent = runContext.Agent.GetService<ChatClientAgent>()
?? throw new InvalidOperationException(
$"{nameof(ServiceStoredSimulatingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " +
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} 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(ServiceStoredSimulatingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
$"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'.");
}
@@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
/// end-to-end behavior with <see cref="ServiceStoredSimulatingChatClient"/> and
/// end-to-end behavior with <see cref="PerServiceCallChatHistoryPersistingChatClient"/> and
/// <see cref="FunctionInvokingChatClient"/>.
/// </summary>
internal static class ChatClientAgentTestHelper
@@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests that verify the end-to-end approval flow behavior of the
/// <see cref="ChatClientAgent"/> class with <see cref="ServiceStoredSimulatingChatClient"/>,
/// <see cref="ChatClientAgent"/> class with <see cref="PerServiceCallChatHistoryPersistingChatClient"/>,
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
/// </summary>
public class ChatClientAgent_ApprovalsTests
@@ -48,7 +48,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -260,7 +260,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -520,7 +520,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 1,
expectedHistory:
@@ -554,7 +554,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 2,
expectedHistory:
@@ -13,15 +13,15 @@ using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the <see cref="ServiceStoredSimulatingChatClient"/> decorator,
/// Contains unit tests for the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator,
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
/// individual service call by default, or marks messages for end-of-run persistence when the
/// <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> option is enabled.
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> option is enabled.
/// </summary>
public class ServiceStoredSimulatingChatClientTests
public class PerServiceCallChatHistoryPersistingChatClientTests
{
/// <summary>
/// Verifies that by default (SimulateServiceStoredChatHistory is false),
/// Verifies that by default (RequirePerServiceCallChatHistoryPersistence is false),
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
/// </summary>
[Fact]
@@ -50,7 +50,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -97,7 +97,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -145,7 +145,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -163,7 +163,7 @@ public class ServiceStoredSimulatingChatClientTests
}
/// <summary>
/// Verifies that the decorator is NOT injected by default (SimulateServiceStoredChatHistory is false).
/// Verifies that the decorator is NOT injected by default (RequirePerServiceCallChatHistoryPersistence is false).
/// </summary>
[Fact]
public void ChatClient_DoesNotContainDecorator_ByDefault()
@@ -175,15 +175,15 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new());
// Assert
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the decorator is injected when SimulateServiceStoredChatHistory is true.
/// Verifies that the decorator is injected when RequirePerServiceCallChatHistoryPersistence is true.
/// </summary>
[Fact]
public void ChatClient_ContainsDecorator_WhenSimulateServiceStoredChatHistory()
public void ChatClient_ContainsDecorator_WhenRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -191,11 +191,11 @@ public class ServiceStoredSimulatingChatClientTests
// Act
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Assert
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.NotNull(decorator);
}
@@ -215,27 +215,27 @@ public class ServiceStoredSimulatingChatClientTests
});
// Assert
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the SimulateServiceStoredChatHistory option is included in Clone().
/// Verifies that the RequirePerServiceCallChatHistoryPersistence option is included in Clone().
/// </summary>
[Fact]
public void ChatClientAgentOptions_Clone_IncludesSimulateServiceStoredChatHistory()
public void ChatClientAgentOptions_Clone_IncludesRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
var options = new ChatClientAgentOptions
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
};
// Act
var cloned = options.Clone();
// Assert
Assert.True(cloned.SimulateServiceStoredChatHistory);
Assert.True(cloned.RequirePerServiceCallChatHistoryPersistence);
}
/// <summary>
@@ -289,7 +289,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -358,7 +358,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -407,7 +407,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -454,7 +454,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -513,7 +513,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object],
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -587,7 +587,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -652,7 +652,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -720,8 +720,8 @@ public class ServiceStoredSimulatingChatClientTests
/// <summary>
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
/// <see cref="ChatClientAgent"/> sets the <see cref="ServiceStoredSimulatingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="ServiceStoredSimulatingChatClient"/> strips it before
/// <see cref="ChatClientAgent"/> sets the <see cref="PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="PerServiceCallChatHistoryPersistingChatClient"/> strips it before
/// forwarding to the inner client.
/// </summary>
[Fact]
@@ -741,7 +741,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -773,7 +773,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -808,7 +808,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Create a session with a real conversation ID.
@@ -842,7 +842,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -862,7 +862,7 @@ public class ServiceStoredSimulatingChatClientTests
/// skip provider resolution in the agent (the decorator handles it).
/// </summary>
[Fact]
public async Task RunAsync_SetsSentinelOnSession_WhenSimulateServiceStoredChatHistoryActiveAsync()
public async Task RunAsync_SetsSentinelOnSession_WhenRequirePerServiceCallChatHistoryPersistenceActiveAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -875,7 +875,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -883,7 +883,7 @@ public class ServiceStoredSimulatingChatClientTests
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — session should have the sentinel conversation ID
Assert.Equal(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
Assert.Equal(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
@@ -924,7 +924,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act & Assert — conflict detection should throw
@@ -969,7 +969,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1025,7 +1025,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1077,7 +1077,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1137,7 +1137,7 @@ public class ServiceStoredSimulatingChatClientTests
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1192,7 +1192,7 @@ public class ServiceStoredSimulatingChatClientTests
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1253,7 +1253,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -1270,7 +1270,7 @@ public class ServiceStoredSimulatingChatClientTests
Assert.Equal("test", messageList[0].Text);
// Assert — session should NOT have the sentinel (agent handles ConversationId at end-of-run)
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
@@ -1291,7 +1291,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -1309,6 +1309,6 @@ public class ServiceStoredSimulatingChatClientTests
Assert.NotEmpty(updates);
// Assert — session should NOT have the sentinel
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
}