// 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 persists chat history and updates session state after each /// individual service call within the loop. /// /// /// /// This decorator is intended to operate between the and the leaf /// in a pipeline. It is activated when /// is . /// /// /// When active, it handles two complementary scenarios: /// /// /// /// Framework-managed chat history /// /// Before each service call, the decorator loads history from the agent's /// and prepends it to the request messages. After each successful call, it persists new messages to /// the provider and returns a sentinel so that /// treats the conversation as service-managed — clearing /// accumulated history between iterations and not injecting duplicate /// during approval-response processing. /// /// /// /// Service-stored chat history /// /// When the underlying service manages its own chat history (real ), /// the decorator updates after each service call so /// that intermediate ConversationId changes are captured immediately rather than only at the end of the run. /// /// /// /// /// 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. The ensures the run context always contains a resolved session, /// even when the caller passes null. An is thrown if no run context is /// available or if the agent is not a . /// /// internal sealed class PerServiceCallChatHistoryPersistingChatClient : DelegatingChatClient { /// /// A sentinel value returned on to signal /// that chat history is being managed downstream. /// /// /// /// When sees a non-null , /// it treats the conversation as service-managed: it clears accumulated history between /// iterations (via FixupHistories) and does not inject /// into the request during approval-response processing (via ProcessFunctionApprovalResponses). /// /// /// This decorator strips the sentinel from on incoming /// requests before forwarding to the inner client, so the underlying model never sees it. /// /// internal const string LocalHistoryConversationId = "_agent_local_chat_history"; /// /// Initializes a new instance of the class. /// /// The underlying chat client that will handle the core operations. public PerServiceCallChatHistoryPersistingChatClient(IChatClient innerClient) : base(innerClient) { } /// public override async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { var (agent, session) = GetRequiredAgentAndSession(); options = StripLocalHistoryConversationId(options); bool isServiceManaged = !string.IsNullOrEmpty(options?.ConversationId); bool isContinuationOrBackground = options?.ContinuationToken is not null || options?.AllowBackgroundResponses is true; bool skipSimulation = isServiceManaged || isContinuationOrBackground; var newMessages = messages as IList ?? messages.ToList(); // When simulating, load history and prepend it. When the service manages // history (real ConversationId) or this is a continuation/background run, // just forward the input messages as-is. var messagesForService = skipSimulation ? newMessages : await agent.LoadChatHistoryAsync(session, newMessages, options, cancellationToken).ConfigureAwait(false); ChatResponse response; try { response = await base.GetResponseAsync(messagesForService, options, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false); throw; } await agent.NotifyProvidersOfNewMessagesAsync(session, newMessages, response.Messages, options, cancellationToken).ConfigureAwait(false); if (isContinuationOrBackground) { // Continuation/background run — the agent's forced end-of-run handles // session ConversationId and persistence; the decorator is a no-op. } else if (isServiceManaged || !string.IsNullOrEmpty(response.ConversationId)) { // Service manages history — update session with the real ConversationId. agent.UpdateSessionConversationId(session, response.ConversationId, cancellationToken); } else { // Normal simulated path — set sentinel so FICC treats this as service-managed. SetSentinelConversationId(response, session); } return response; } /// public override async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var (agent, session) = GetRequiredAgentAndSession(); options = StripLocalHistoryConversationId(options); bool isServiceManaged = !string.IsNullOrEmpty(options?.ConversationId); bool isContinuationOrBackground = options?.ContinuationToken is not null || options?.AllowBackgroundResponses is true; bool skipSimulation = isServiceManaged || isContinuationOrBackground; // Snapshot the input messages into a private list. The caller (typically // FunctionInvokingChatClient) reuses a single mutable buffer across iterations, // and the streaming path can defer persistence until after the caller has already // mutated that buffer for the next iteration (e.g. on the cooperative early-exit // path NotifyProvidersOfEarlyExitInputAsync). Aliasing the caller's list would // then cause us to persist the wrong messages — losing FunctionResultContent and // corrupting history with dangling FunctionCallContent. var newMessages = messages.ToList(); // When simulating, load history and prepend it. When the service manages // history (real ConversationId) or this is a continuation/background run, // just forward the input messages as-is. var messagesForService = skipSimulation ? newMessages : await agent.LoadChatHistoryAsync(session, newMessages, options, cancellationToken).ConfigureAwait(false); List responseUpdates = []; IAsyncEnumerator enumerator; try { enumerator = base.GetStreamingResponseAsync(messagesForService, options, cancellationToken).GetAsyncEnumerator(cancellationToken); } catch (Exception ex) { await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false); throw; } bool loopExitedNormally = false; bool serviceErrorOccurred = false; try { bool hasUpdates; try { hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false); } catch (Exception ex) { serviceErrorOccurred = true; await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false); throw; } while (hasUpdates) { var update = enumerator.Current; responseUpdates.Add(update.Clone()); // If the service returned a real ConversationId on any update, remember that. // Otherwise stamp our sentinel so FICC treats this as service-managed — // unless this is a continuation/background run where the agent handles everything. if (!string.IsNullOrEmpty(update.ConversationId)) { isServiceManaged = true; } else if (!skipSimulation) { update.ConversationId = LocalHistoryConversationId; } yield return update; try { hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false); } catch (Exception ex) { serviceErrorOccurred = true; await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false); throw; } } loopExitedNormally = true; } finally { // If the iterator was disposed by the consumer before completing — e.g. // ToolApprovalAgent does `yield break` after emitting an approval request — persist // the input messages so that any in-flight FunctionResultContent paired with // previously-persisted FunctionCallContent is not lost between turns. We only do // this on the cooperative-pause path; service errors deliberately do NOT persist // input messages (history of failed calls is the caller's responsibility, e.g. // by retrying or starting from an earlier point). if (!loopExitedNormally && !serviceErrorOccurred) { // Prefer the original cancellation token so cleanup remains responsive; fall // back to None only if the caller's token has already been canceled (otherwise // the notify call would observe the cancellation, throw, and mask the // original early-exit reason). var persistToken = cancellationToken.IsCancellationRequested ? CancellationToken.None : cancellationToken; try { await NotifyProvidersOfEarlyExitInputAsync(agent, session, newMessages, options, persistToken).ConfigureAwait(false); } catch { // Best-effort persistence; swallow to avoid masking the original exit reason. } } // Always dispose the underlying enumerator on every exit path (normal completion, // exception, or early consumer disposal) to release the underlying HTTP/stream. await enumerator.DisposeAsync().ConfigureAwait(false); } var chatResponse = responseUpdates.ToChatResponse(); await agent.NotifyProvidersOfNewMessagesAsync(session, newMessages, chatResponse.Messages, options, cancellationToken).ConfigureAwait(false); if (isContinuationOrBackground) { // Continuation/background run — the agent's forced end-of-run handles // session ConversationId and persistence; the decorator is a no-op. } else if (isServiceManaged) { // Service manages history — update session with the real ConversationId. agent.UpdateSessionConversationId(session, chatResponse.ConversationId, cancellationToken); } else { // Normal simulated path — set sentinel on session. session.ConversationId = LocalHistoryConversationId; } } /// /// Notifies s of the input messages only (no response /// messages) on the cooperative early-exit path — e.g. when ToolApprovalAgent /// does yield break after emitting an approval request. This ensures any /// in-flight paired with previously-persisted /// is not orphaned in the persisted chat history. /// The notification is routed through the same success channel used at the end of a /// normal run; the providers themselves decide how (or whether) to persist. /// private static async Task NotifyProvidersOfEarlyExitInputAsync( ChatClientAgent agent, ChatClientAgentSession session, List newMessages, ChatOptions? options, CancellationToken cancellationToken) { if (newMessages.Count == 0) { return; } await agent.NotifyProvidersOfNewMessagesAsync(session, newMessages, [], options, cancellationToken).ConfigureAwait(false); } /// /// Sets the sentinel on the response and session /// so that treats the conversation as service-managed. /// private static void SetSentinelConversationId(ChatResponse response, ChatClientAgentSession session) { response.ConversationId = LocalHistoryConversationId; session.ConversationId = LocalHistoryConversationId; } /// /// Gets the current and from the run context. /// private static (ChatClientAgent Agent, ChatClientAgentSession Session) GetRequiredAgentAndSession() { var runContext = AIAgent.CurrentRunContext ?? throw new InvalidOperationException( $"{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() ?? throw new InvalidOperationException( $"{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(PerServiceCallChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " + $"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'."); } return (chatClientAgent, chatClientAgentSession); } /// /// If the carry the sentinel, /// returns a clone with the conversation ID cleared so the inner client never sees it. /// Otherwise returns the original unchanged. /// private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options) { if (options?.ConversationId == LocalHistoryConversationId) { options = options.Clone(); options.ConversationId = null; } return options; } }