mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
ae57616b32
* Refactor harness console rendering * Fix formatting issues * Address PR comments
346 lines
13 KiB
C#
346 lines
13 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// A delegating chat client that supports injecting messages into the function execution loop.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <see cref="FunctionInvokingChatClient"/>
|
|
/// and the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> (or the leaf <see cref="IChatClient"/>)
|
|
/// in a <see cref="ChatClientAgent"/> pipeline.
|
|
/// </para>
|
|
/// <para>
|
|
/// The injected messages queue is stored per-session in the <see cref="AgentSession.StateBag"/>, ensuring
|
|
/// isolation between concurrent sessions.
|
|
/// </para>
|
|
/// <para>
|
|
/// After each service call, if no actionable <see cref="FunctionCallContent"/> 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
|
|
/// <see cref="FunctionInvokingChatClient"/> loop.
|
|
/// </para>
|
|
/// <para>
|
|
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
|
|
/// current session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
|
|
/// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> or
|
|
/// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/>
|
|
/// method is called.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
|
public sealed class MessageInjectingChatClient : DelegatingChatClient
|
|
{
|
|
/// <summary>
|
|
/// The key used to store the pending injected messages queue in the session's <see cref="AgentSessionStateBag"/>.
|
|
/// </summary>
|
|
internal const string PendingMessagesStateKey = "MessageInjectingChatClient.PendingInjectedMessages";
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="MessageInjectingChatClient"/> class.
|
|
/// </summary>
|
|
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
|
|
public MessageInjectingChatClient(IChatClient innerClient)
|
|
: base(innerClient)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override async Task<ChatResponse> GetResponseAsync(
|
|
IEnumerable<ChatMessage> messages,
|
|
ChatOptions? options = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var session = GetRequiredSession();
|
|
var queue = GetOrCreateQueue(session);
|
|
|
|
var newMessages = DrainInjectedMessages(queue, messages as IList<ChatMessage> ?? 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<ChatMessage>());
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
|
IEnumerable<ChatMessage> messages,
|
|
ChatOptions? options = null,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
var session = GetRequiredSession();
|
|
var queue = GetOrCreateQueue(session);
|
|
|
|
var newMessages = DrainInjectedMessages(queue, messages as IList<ChatMessage> ?? 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<ChatMessage>());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueues one or more messages to be used at the next opportunity.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <param name="session">The agent session to enqueue messages for.</param>
|
|
/// <param name="messages">The messages to enqueue.</param>
|
|
public void EnqueueMessages(AgentSession session, IEnumerable<ChatMessage> messages)
|
|
{
|
|
Throw.IfNull(session);
|
|
Throw.IfNull(messages);
|
|
|
|
var queue = GetOrCreateQueue(session);
|
|
|
|
lock (queue)
|
|
{
|
|
foreach (var message in messages)
|
|
{
|
|
queue.Add(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a snapshot of the pending injected messages for the specified session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <param name="session">The agent session to check.</param>
|
|
/// <returns>A read-only list of pending messages, or an empty list if none are pending.</returns>
|
|
public IReadOnlyList<ChatMessage> GetPendingMessages(AgentSession session)
|
|
{
|
|
Throw.IfNull(session);
|
|
|
|
if (!session.StateBag.TryGetValue<List<ChatMessage>>(PendingMessagesStateKey, out var queue) || queue is null)
|
|
{
|
|
return Array.Empty<ChatMessage>();
|
|
}
|
|
|
|
lock (queue)
|
|
{
|
|
return queue.Count == 0 ? Array.Empty<ChatMessage>() : queue.ToList();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or creates the pending injected messages queue from the session's <see cref="AgentSessionStateBag"/>.
|
|
/// </summary>
|
|
private static List<ChatMessage> GetOrCreateQueue(AgentSession session)
|
|
{
|
|
if (session.StateBag.TryGetValue<List<ChatMessage>>(PendingMessagesStateKey, out var queue))
|
|
{
|
|
return queue!;
|
|
}
|
|
|
|
var newQueue = new List<ChatMessage>();
|
|
session.StateBag.SetValue(PendingMessagesStateKey, newQueue);
|
|
return newQueue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current <see cref="AgentSession"/> from the run context.
|
|
/// </summary>
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static IList<ChatMessage> DrainInjectedMessages(List<ChatMessage> queue, IList<ChatMessage> newMessages)
|
|
{
|
|
lock (queue)
|
|
{
|
|
if (queue.Count == 0)
|
|
{
|
|
return newMessages;
|
|
}
|
|
|
|
var combined = new List<ChatMessage>(newMessages);
|
|
combined.AddRange(queue);
|
|
queue.Clear();
|
|
return combined;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether any message in the list contains a <see cref="FunctionCallContent"/>
|
|
/// that is not marked as <see cref="FunctionCallContent.InformationalOnly"/>.
|
|
/// </summary>
|
|
private static bool HasActionableFunctionCalls(IList<ChatMessage> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether a streaming update contains a <see cref="FunctionCallContent"/>
|
|
/// that is not marked as <see cref="FunctionCallContent.InformationalOnly"/>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Propagates the <paramref name="conversationId"/> from the service response into
|
|
/// <paramref name="options"/> so that subsequent loop iterations continue within the
|
|
/// same conversation. Clones <paramref name="options"/> before mutating to avoid
|
|
/// affecting the caller's instance.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|