// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// /// A delegating chat client that supports injecting messages into the function execution loop. /// /// /// /// This decorator enables external code (such as tool delegates) to enqueue messages that will be /// sent to the underlying model at the next opportunity. It sits between the /// and the (or the leaf ) /// in a pipeline. /// /// /// The injected messages queue is stored per-session in the , ensuring /// isolation between concurrent sessions. /// /// /// After each service call, if no actionable is returned but injected /// messages are pending, the decorator loops internally and calls the inner client again with the new /// messages. When actionable function calls are present, control returns to the parent /// loop. /// /// /// This chat client must be used within the context of a running . It retrieves the /// current session from , which is set automatically when an agent's /// or /// /// method is called. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class MessageInjectingChatClient : DelegatingChatClient { /// /// The key used to store the pending injected messages queue in the session's . /// internal const string PendingMessagesStateKey = "MessageInjectingChatClient.PendingInjectedMessages"; /// /// Initializes a new instance of the class. /// /// The underlying chat client that will handle the core operations. public MessageInjectingChatClient(IChatClient innerClient) : base(innerClient) { } /// public override async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { var session = GetRequiredSession(); var queue = GetOrCreateQueue(session); var newMessages = DrainInjectedMessages(queue, messages as IList ?? messages.ToList()); // Loop to process injected messages: after each service call, if no actionable function calls // are pending but new messages have been injected into the queue, we call the service again // so the model can process them. The loop exits when the response contains actionable // function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty. while (true) { var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false); // If the response contains actionable function calls, the parent FunctionInvokingChatClient // loop will iterate — return immediately so it can process them. if (HasActionableFunctionCalls(response.Messages)) { return response; } // No actionable function calls. If there are pending injected messages, loop again // to send them to the service. Otherwise, we're done. bool queueEmpty; lock (queue) { queueEmpty = queue.Count == 0; } if (queueEmpty) { return response; } // Propagate any ConversationId returned by the service so subsequent iterations // continue within the same conversation. UpdateOptionsForNextIteration(ref options, response.ConversationId); newMessages = DrainInjectedMessages(queue, Array.Empty()); } } /// public override async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var session = GetRequiredSession(); var queue = GetOrCreateQueue(session); var newMessages = DrainInjectedMessages(queue, messages as IList ?? messages.ToList()); // Loop to process injected messages: after each service call, if no actionable function calls // are pending but new messages have been injected into the queue, we call the service again // so the model can process them. The loop exits when the response contains actionable // function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty. while (true) { bool hasActionableFunctionCalls = false; string? lastConversationId = null; var enumerator = base.GetStreamingResponseAsync(newMessages, options, cancellationToken).GetAsyncEnumerator(cancellationToken); try { while (await enumerator.MoveNextAsync().ConfigureAwait(false)) { var update = enumerator.Current; // Check each update for actionable function call content as it streams through. if (!hasActionableFunctionCalls && HasActionableFunctionCalls(update)) { hasActionableFunctionCalls = true; } // Track the latest ConversationId from the stream. if (update.ConversationId is not null) { lastConversationId = update.ConversationId; } yield return update; } } finally { await enumerator.DisposeAsync().ConfigureAwait(false); } // If the response contains actionable function calls, the parent FunctionInvokingChatClient // loop will iterate — return immediately so it can process them. if (hasActionableFunctionCalls) { yield break; } // No actionable function calls. If there are pending injected messages, loop again // to send them to the service. Otherwise, we're done. bool queueEmpty; lock (queue) { queueEmpty = queue.Count == 0; } if (queueEmpty) { yield break; } // Propagate any ConversationId returned by the service so subsequent iterations // continue within the same conversation. UpdateOptionsForNextIteration(ref options, lastConversationId); newMessages = DrainInjectedMessages(queue, Array.Empty()); } } /// /// Enqueues one or more messages to be used at the next opportunity. /// /// /// This method is thread-safe and can be called concurrently from tool delegates or other code /// while the function execution loop is in progress. The enqueued messages will be picked up /// at the next opportunity. /// /// The agent session to enqueue messages for. /// The messages to enqueue. public void EnqueueMessages(AgentSession session, IEnumerable messages) { Throw.IfNull(session); Throw.IfNull(messages); var queue = GetOrCreateQueue(session); lock (queue) { foreach (var message in messages) { queue.Add(message); } } } /// /// Gets a snapshot of the pending injected messages for the specified session. /// /// /// Returns a copy of the current pending messages that have not yet been consumed by the /// injection loop. This can be used to display pending messages to the user. The returned /// list is a point-in-time snapshot; messages may be consumed between calls. /// /// The agent session to check. /// A read-only list of pending messages, or an empty list if none are pending. public IReadOnlyList GetPendingMessages(AgentSession session) { Throw.IfNull(session); if (!session.StateBag.TryGetValue>(PendingMessagesStateKey, out var queue) || queue is null) { return Array.Empty(); } lock (queue) { return queue.Count == 0 ? Array.Empty() : queue.ToList(); } } /// /// Gets or creates the pending injected messages queue from the session's . /// private static List GetOrCreateQueue(AgentSession session) { if (session.StateBag.TryGetValue>(PendingMessagesStateKey, out var queue)) { return queue!; } var newQueue = new List(); session.StateBag.SetValue(PendingMessagesStateKey, newQueue); return newQueue; } /// /// Gets the current from the run context. /// private static AgentSession GetRequiredSession() { var runContext = AIAgent.CurrentRunContext ?? throw new InvalidOperationException( $"{nameof(MessageInjectingChatClient)} 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."); return runContext.Session ?? throw new InvalidOperationException( $"{nameof(MessageInjectingChatClient)} requires a session. " + "The current run context does not have a session."); } /// /// Drains all pending injected messages from the queue and returns a new list combining /// the original messages with the drained messages. The original list is never modified. /// private static IList DrainInjectedMessages(List queue, IList newMessages) { lock (queue) { if (queue.Count == 0) { return newMessages; } var combined = new List(newMessages); combined.AddRange(queue); queue.Clear(); return combined; } } /// /// Determines whether any message in the list contains a /// that is not marked as . /// private static bool HasActionableFunctionCalls(IList responseMessages) { for (int i = 0; i < responseMessages.Count; i++) { var contents = responseMessages[i].Contents; for (int j = 0; j < contents.Count; j++) { if (contents[j] is FunctionCallContent fcc && !fcc.InformationalOnly) { return true; } } } return false; } /// /// Determines whether a streaming update contains a /// that is not marked as . /// private static bool HasActionableFunctionCalls(ChatResponseUpdate update) { var contents = update.Contents; for (int i = 0; i < contents.Count; i++) { if (contents[i] is FunctionCallContent fcc && !fcc.InformationalOnly) { return true; } } return false; } /// /// Propagates the from the service response into /// so that subsequent loop iterations continue within the /// same conversation. Clones before mutating to avoid /// affecting the caller's instance. /// private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId) { if (options is null) { if (conversationId is not null) { options = new() { ConversationId = conversationId }; } } else if (options.ConversationId != conversationId) { options = options.Clone(); options.ConversationId = conversationId; } } }