mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: feat: Update GroupChatManager semantics to match other Orchestration patterns (#6140)
* Refactor group chat workflow to prevent message echoing and enhance checkpointing - Updated GroupChatWorkflowBuilder to disable forwarding incoming messages to prevent duplicates. - Enhanced RoundRobinGroupChatManager with checkpointing support to preserve state across executions. - Modified GroupChatHost to maintain a history of messages and track the current speaker for message broadcasting. - Implemented broadcasting logic to ensure participants receive messages from others while excluding their own responses. - Added comprehensive unit tests for group chat orchestration, including scenarios for tool approval and function calls. - Introduced a new ApprovalHarness for testing tool invocation and approval workflows. * fixup: format * Add JSON serialization support for GroupChatManagerState and RoundRobinGroupChatManagerState --------- Co-authored-by: Jacob Alber <jalber@lokitoth.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
b1e9efee7e
commit
d2f79930d5
@@ -1,6 +1,8 @@
|
||||
// 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;
|
||||
@@ -13,6 +15,16 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// </summary>
|
||||
public abstract class GroupChatManager
|
||||
{
|
||||
// The state key under which GroupChatManager persists its own (non-subclass) state on the
|
||||
// raw IWorkflowContext supplied by the hosting GroupChatHost executor.
|
||||
internal const string BaseStateKey = "GroupChatManager";
|
||||
|
||||
// Prefix automatically applied to every key a subclass writes through the wrapped context
|
||||
// supplied to OnCheckpointingAsync / OnCheckpointRestoredAsync. Keeps subclass-defined
|
||||
// state in its own namespace so it cannot collide with the host's state keys nor with
|
||||
// BaseStateKey itself.
|
||||
internal const string SubclassStateKeyPrefix = "GroupChatManager_";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GroupChatManager"/> class.
|
||||
/// </summary>
|
||||
@@ -48,12 +60,22 @@ public abstract class GroupChatManager
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Filters the chat history before it's passed to the next agent.
|
||||
/// Filters the messages broadcast to participants for the current turn.
|
||||
/// </summary>
|
||||
/// <param name="history">The chat history to filter.</param>
|
||||
/// <remarks>
|
||||
/// Under the broadcast model, each participant maintains its own per-agent session (history)
|
||||
/// through its <see cref="Specialized.AIAgentHostExecutor"/>. The host distributes new messages
|
||||
/// (initial user input on the first turn, the most recent speaker's response on subsequent turns)
|
||||
/// to every participant — except the speaker that produced them — so every participant's session
|
||||
/// stays synchronized. This method lets the manager shape that broadcast payload (for example,
|
||||
/// to omit certain messages or to inject orchestrator-visible annotations). The full canonical
|
||||
/// conversation is still available to <see cref="SelectNextAgentAsync"/> and
|
||||
/// <see cref="ShouldTerminateAsync"/>.
|
||||
/// </remarks>
|
||||
/// <param name="history">The new messages about to be broadcast to participants this turn.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The filtered chat history.</returns>
|
||||
/// <returns>The filtered message list to broadcast.</returns>
|
||||
protected internal virtual ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
|
||||
IReadOnlyList<ChatMessage> history,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
@@ -78,4 +100,125 @@ public abstract class GroupChatManager
|
||||
{
|
||||
this.IterationCount = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the hosting group chat workflow is checkpointing, giving subclasses a chance to
|
||||
/// persist any additional state they maintain (e.g., a round-robin cursor or an LLM session).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default implementation is a no-op. Base-class state (currently
|
||||
/// <see cref="IterationCount"/>) is persisted automatically by the hosting
|
||||
/// <see cref="Specialized.GroupChatHost"/> before this method is invoked; subclasses do not
|
||||
/// need to call <c>base.OnCheckpointingAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The supplied <paramref name="context"/> is a wrapper that transparently prefixes every
|
||||
/// state key with <c>"GroupChatManager_"</c>, isolating subclass state from the host's own
|
||||
/// state keys (and from the reserved base-state key). Implementations therefore may use any
|
||||
/// human-readable key (e.g., <c>"next_index"</c>) without worrying about collisions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">A wrapped workflow context that scopes state keys to the
|
||||
/// <see cref="GroupChatManager"/> subclass namespace.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
protected virtual ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the hosting group chat workflow is being restored from a checkpoint, giving
|
||||
/// subclasses a chance to hydrate any additional state they persisted in
|
||||
/// <see cref="OnCheckpointingAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation is a no-op. Base-class state (currently
|
||||
/// <see cref="IterationCount"/>) is restored automatically by the hosting
|
||||
/// <see cref="Specialized.GroupChatHost"/> before this method is invoked; subclasses do not
|
||||
/// need to call <c>base.OnCheckpointRestoredAsync</c>. The supplied <paramref name="context"/>
|
||||
/// uses the same key-prefixing wrapper as <see cref="OnCheckpointingAsync"/>.
|
||||
/// </remarks>
|
||||
/// <param name="context">A wrapped workflow context that scopes state keys to the
|
||||
/// <see cref="GroupChatManager"/> subclass namespace.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
protected virtual ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
// Root checkpoint entry point invoked by the hosting GroupChatHost. Persists the manager's
|
||||
// own base state under the reserved BaseStateKey on the raw context, then delegates to the
|
||||
// subclass-facing OnCheckpointingAsync hook with a wrapped context that prefixes every key
|
||||
// with SubclassStateKeyPrefix.
|
||||
internal async ValueTask CheckpointAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.QueueStateUpdateAsync(BaseStateKey, new GroupChatManagerState(this.IterationCount), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await this.OnCheckpointingAsync(new PrefixingWorkflowContext(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Root restore entry point invoked by the hosting GroupChatHost. Symmetric to CheckpointAsync.
|
||||
internal async ValueTask RestoreCheckpointAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GroupChatManagerState? state = await context.ReadStateAsync<GroupChatManagerState>(BaseStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
this.IterationCount = state?.IterationCount ?? 0;
|
||||
await this.OnCheckpointRestoredAsync(new PrefixingWorkflowContext(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record GroupChatManagerState(int IterationCount);
|
||||
|
||||
// IWorkflowContext decorator that prepends a fixed prefix to every state key passed through it.
|
||||
// All non-state members (events, message sending, output yielding, halt requests, trace context,
|
||||
// and runtime characteristics) delegate directly to the wrapped context.
|
||||
internal sealed class PrefixingWorkflowContext(IWorkflowContext inner, string prefix) : IWorkflowContext
|
||||
{
|
||||
private readonly IWorkflowContext _inner = Throw.IfNull(inner);
|
||||
private readonly string _prefix = Throw.IfNullOrEmpty(prefix);
|
||||
|
||||
public IReadOnlyDictionary<string, string>? TraceContext => this._inner.TraceContext;
|
||||
|
||||
public bool ConcurrentRunsEnabled => this._inner.ConcurrentRunsEnabled;
|
||||
|
||||
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
|
||||
=> this._inner.AddEventAsync(workflowEvent, cancellationToken);
|
||||
|
||||
public ValueTask SendMessageAsync(object message, string? targetId, CancellationToken cancellationToken = default)
|
||||
=> this._inner.SendMessageAsync(message, targetId, cancellationToken);
|
||||
|
||||
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
|
||||
=> this._inner.YieldOutputAsync(output, cancellationToken);
|
||||
|
||||
public ValueTask RequestHaltAsync() => this._inner.RequestHaltAsync();
|
||||
|
||||
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
=> this._inner.ReadStateAsync<T>(this.Wrap(key), scopeName, cancellationToken);
|
||||
|
||||
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
=> this._inner.ReadOrInitStateAsync(this.Wrap(key), initialStateFactory, scopeName, cancellationToken);
|
||||
|
||||
public async ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
HashSet<string> rawKeys = await this._inner.ReadStateKeysAsync(scopeName, cancellationToken).ConfigureAwait(false);
|
||||
return [.. rawKeys.Where(k => k.StartsWith(this._prefix, StringComparison.Ordinal))
|
||||
.Select(k => k.Substring(this._prefix.Length))];
|
||||
}
|
||||
|
||||
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
=> this._inner.QueueStateUpdateAsync(this.Wrap(key), value, scopeName, cancellationToken);
|
||||
|
||||
public async ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Clearing the entire underlying scope would also remove keys owned by the host and other
|
||||
// subsystems sharing the executor's default scope. Restrict the clear to keys carrying
|
||||
// this wrapper's prefix.
|
||||
HashSet<string> rawKeys = await this._inner.ReadStateKeysAsync(scopeName, cancellationToken).ConfigureAwait(false);
|
||||
foreach (string rawKey in rawKeys)
|
||||
{
|
||||
if (rawKey.StartsWith(this._prefix, StringComparison.Ordinal))
|
||||
{
|
||||
await this._inner.QueueStateUpdateAsync<object>(rawKey, null, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string Wrap(string key) => this._prefix + Throw.IfNullOrEmpty(key);
|
||||
}
|
||||
|
||||
@@ -75,10 +75,14 @@ public sealed class GroupChatWorkflowBuilder
|
||||
{
|
||||
AIAgent[] agents = this._participants.ToArray();
|
||||
|
||||
// GroupChatHost owns the canonical conversation and broadcasts messages directly to every
|
||||
// participant. Participants therefore must not echo their incoming messages back to the host
|
||||
// (which would cause duplicates), but must still reframe other agents' assistant messages as
|
||||
// user messages so each agent's own session reads coherently.
|
||||
AIAgentHostOptions options = new()
|
||||
{
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = true
|
||||
ForwardIncomingMessages = false
|
||||
};
|
||||
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => a.BindAsExecutor(options));
|
||||
|
||||
@@ -69,4 +69,23 @@ public class RoundRobinGroupChatManager : GroupChatManager
|
||||
base.Reset();
|
||||
this._nextIndex = 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.QueueStateUpdateAsync(StateKey, new RoundRobinGroupChatManagerState(this._nextIndex), cancellationToken: cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RoundRobinGroupChatManagerState? state = await context.ReadStateAsync<RoundRobinGroupChatManagerState>(StateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
this._nextIndex = state?.NextIndex ?? 0;
|
||||
if (this._nextIndex < 0 || this._nextIndex >= this._agents.Count)
|
||||
{
|
||||
this._nextIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private const string StateKey = "next_index";
|
||||
}
|
||||
|
||||
internal sealed record RoundRobinGroupChatManagerState(int NextIndex);
|
||||
|
||||
@@ -20,12 +20,25 @@ internal sealed class GroupChatHost(
|
||||
AutoSendTurnToken = false
|
||||
};
|
||||
|
||||
private const string HistoryStateKey = nameof(_history);
|
||||
private const string CurrentSpeakerStateKey = nameof(_currentSpeakerExecutorId);
|
||||
|
||||
private readonly AIAgent[] _agents = agents;
|
||||
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
|
||||
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
|
||||
|
||||
private GroupChatManager? _manager;
|
||||
|
||||
// Canonical conversation accumulated across turns. Each participant maintains its own per-agent
|
||||
// session/thread; the host keeps this only as the source of truth for the manager hooks
|
||||
// (SelectNextAgentAsync / ShouldTerminateAsync) and for the workflow's final output.
|
||||
private List<ChatMessage> _history = [];
|
||||
|
||||
// Executor id of the participant we most recently dispatched a TurnToken to – i.e., the current
|
||||
// speaker whose response is about to arrive. Used to exclude that participant from the next
|
||||
// broadcast (its own session already contains the message it produced).
|
||||
private string? _currentSpeakerExecutorId;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> base.ConfigureProtocol(protocolBuilder).YieldsOutput<List<ChatMessage>>();
|
||||
|
||||
@@ -33,30 +46,105 @@ internal sealed class GroupChatHost(
|
||||
{
|
||||
this._manager ??= this._managerFactory(this._agents);
|
||||
|
||||
if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false))
|
||||
// The delta arriving here is either the initial user input (turn 0) or the most recent speaker's
|
||||
// response (subsequent turns) – participants no longer echo incoming messages back to the host.
|
||||
if (messages.Count > 0)
|
||||
{
|
||||
var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
|
||||
this._history.AddRange(messages);
|
||||
}
|
||||
|
||||
if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
|
||||
this._agentMap.TryGetValue(nextAgent, out var executor))
|
||||
if (await this._manager.ShouldTerminateAsync(this._history, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
await this.CompleteAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.Count > 0)
|
||||
{
|
||||
IEnumerable<ChatMessage> filteredDelta = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
List<ChatMessage> broadcastMessages = filteredDelta is null
|
||||
? messages
|
||||
: (ReferenceEquals(filteredDelta, messages) ? messages : [.. filteredDelta]);
|
||||
|
||||
if (broadcastMessages.Count > 0)
|
||||
{
|
||||
this._manager.IterationCount++;
|
||||
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
await this.BroadcastAsync(broadcastMessages, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
this._manager = null;
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
if (await this._manager.SelectNextAgentAsync(this._history, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
|
||||
this._agentMap.TryGetValue(nextAgent, out ExecutorBinding? executor))
|
||||
{
|
||||
this._manager.IterationCount++;
|
||||
this._currentSpeakerExecutorId = executor.Id;
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.CompleteAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private ValueTask BroadcastAsync(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
List<Task>? sendTasks = null;
|
||||
foreach (ExecutorBinding participant in this._agentMap.Values)
|
||||
{
|
||||
if (string.Equals(participant.Id, this._currentSpeakerExecutorId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(sendTasks ??= []).Add(context.SendMessageAsync(messages, participant.Id, cancellationToken).AsTask());
|
||||
}
|
||||
|
||||
return sendTasks is null ? default : new ValueTask(Task.WhenAll(sendTasks));
|
||||
}
|
||||
|
||||
private async ValueTask CompleteAsync(IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
List<ChatMessage> output = this._history;
|
||||
this._history = [];
|
||||
this._currentSpeakerExecutorId = null;
|
||||
this._manager = null;
|
||||
|
||||
await context.YieldOutputAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected override ValueTask ResetAsync()
|
||||
{
|
||||
this._manager = null;
|
||||
this._history = [];
|
||||
this._currentSpeakerExecutorId = null;
|
||||
|
||||
return base.ResetAsync();
|
||||
}
|
||||
|
||||
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
|
||||
|
||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Task historyTask = context.QueueStateUpdateAsync(HistoryStateKey, this._history, cancellationToken: cancellationToken).AsTask();
|
||||
Task currentSpeakerTask = context.QueueStateUpdateAsync(CurrentSpeakerStateKey, this._currentSpeakerExecutorId, cancellationToken: cancellationToken).AsTask();
|
||||
Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask();
|
||||
|
||||
// Eagerly materialize the manager so subclass state (e.g., the round-robin cursor) gets
|
||||
// persisted on every checkpoint, even if no turn has been taken yet since the host was constructed.
|
||||
this._manager ??= this._managerFactory(this._agents);
|
||||
Task managerTask = this._manager.CheckpointAsync(context, cancellationToken).AsTask();
|
||||
|
||||
await Task.WhenAll(historyTask, currentSpeakerTask, baseTask, managerTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._history = await context.ReadStateAsync<List<ChatMessage>>(HistoryStateKey, cancellationToken: cancellationToken).ConfigureAwait(false) ?? [];
|
||||
this._currentSpeakerExecutorId = await context.ReadStateAsync<string?>(CurrentSpeakerStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Instantiate the manager eagerly so its restore hook can rehydrate IterationCount and any
|
||||
// subclass-defined state (e.g., RoundRobinGroupChatManager._nextIndex).
|
||||
this._manager = this._managerFactory(this._agents);
|
||||
await this._manager.RestoreCheckpointAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ internal static partial class WorkflowsJsonUtilities
|
||||
[JsonSerializable(typeof(MagenticPlanReviewRequest))]
|
||||
[JsonSerializable(typeof(MagenticPlanReviewResponse))]
|
||||
[JsonSerializable(typeof(MagenticTaskState))]
|
||||
[JsonSerializable(typeof(GroupChatManagerState))]
|
||||
[JsonSerializable(typeof(RoundRobinGroupChatManagerState))]
|
||||
[JsonSerializable(typeof(ResetChatSignal))]
|
||||
|
||||
// Event Types
|
||||
|
||||
@@ -243,13 +243,36 @@ public class AgentWorkflowBuilderTests
|
||||
Assert.Null(result[0].AuthorName);
|
||||
Assert.Equal(UserInput, result[0].Text);
|
||||
|
||||
// The group-chat host broadcasts each new message (initial user input + each speaker's
|
||||
// response) to every participant except the speaker that produced it. The selected
|
||||
// speaker therefore sees only what's been broadcast to it since its previous turn.
|
||||
string[] agentIds = ["agent1", "agent2", "agent3"];
|
||||
List<string>[] buffers = new List<string>[NumAgents];
|
||||
for (int a = 0; a < NumAgents; a++)
|
||||
{
|
||||
buffers[a] = [UserInput];
|
||||
}
|
||||
|
||||
string[] texts = new string[maxIterations + 1];
|
||||
texts[0] = UserInput;
|
||||
string expectedTotal = string.Empty;
|
||||
for (int i = 1; i < maxIterations + 1; i++)
|
||||
{
|
||||
string id = $"agent{((i - 1) % NumAgents) + 1}";
|
||||
texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}";
|
||||
int speakerIdx = (i - 1) % NumAgents;
|
||||
string id = agentIds[speakerIdx];
|
||||
string concatReceived = string.Concat(buffers[speakerIdx]);
|
||||
texts[i] = $"{id}{Double(concatReceived)}";
|
||||
buffers[speakerIdx].Clear();
|
||||
for (int a = 0; a < NumAgents; a++)
|
||||
{
|
||||
if (a == speakerIdx)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
buffers[a].Add(texts[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(ChatRole.Assistant, result[i].Role);
|
||||
Assert.Equal(id, result[i].AuthorName);
|
||||
Assert.Equal(texts[i], result[i].Text);
|
||||
@@ -338,4 +361,257 @@ public class AgentWorkflowBuilderTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAgent(string name) : AIAgent
|
||||
{
|
||||
public List<List<string>> Invocations { get; } = [];
|
||||
|
||||
public override string Name => name;
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new RecordingAgentSession());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new RecordingAgentSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
this.Invocations.Add(messages.Select(m => m.Text).ToList());
|
||||
|
||||
string id = Guid.NewGuid().ToString("N");
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, name) { AuthorName = name, MessageId = id };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAgentSession() : AgentSession();
|
||||
|
||||
[Fact]
|
||||
public async Task BuildGroupChat_BroadcastsDeltaAndTargetsTurnTokenToSpeakerOnlyAsync()
|
||||
{
|
||||
var agentA = new RecordingAgent("agentA");
|
||||
var agentB = new RecordingAgent("agentB");
|
||||
var agentC = new RecordingAgent("agentC");
|
||||
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 4 })
|
||||
.AddParticipants(agentA, agentB, agentC)
|
||||
.Build();
|
||||
|
||||
const string UserInput = "hello";
|
||||
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(5, result.Count); // initial user input + 4 agent turns
|
||||
Assert.Collection(
|
||||
result,
|
||||
m => Assert.Equal(UserInput, m.Text),
|
||||
m => Assert.Equal("agentA", m.Text),
|
||||
m => Assert.Equal("agentB", m.Text),
|
||||
m => Assert.Equal("agentC", m.Text),
|
||||
m => Assert.Equal("agentA", m.Text));
|
||||
|
||||
// Each agent's TurnToken fires exactly when it is the selected speaker — invocation counts
|
||||
// confirm only the chosen participant receives a TurnToken on each round.
|
||||
Assert.Equal(2, agentA.Invocations.Count);
|
||||
Assert.Single(agentB.Invocations);
|
||||
Assert.Single(agentC.Invocations);
|
||||
|
||||
// Turn 1: agentA is the first speaker. Initial broadcast went to every participant, so
|
||||
// agentA's only buffered message is the user input.
|
||||
Assert.Equal([UserInput], agentA.Invocations[0]);
|
||||
|
||||
// Turn 2: agentB. It received the initial broadcast (user input) plus turn-1 broadcast of
|
||||
// agentA's response (agentA itself is excluded as the last speaker).
|
||||
Assert.Equal([UserInput, "agentA"], agentB.Invocations[0]);
|
||||
|
||||
// Turn 3: agentC. It also received every broadcast so far (it has never been excluded).
|
||||
Assert.Equal([UserInput, "agentA", "agentB"], agentC.Invocations[0]);
|
||||
|
||||
// Turn 4: agentA again. It was excluded on turn 2's broadcast (its own response), but
|
||||
// received turn-3 (agentB's response) and turn-4 (agentC's response) deltas.
|
||||
Assert.Equal(["agentB", "agentC"], agentA.Invocations[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildGroupChat_UpdateHistoryAsync_FiltersBroadcastPayloadAsync()
|
||||
{
|
||||
var agentA = new RecordingAgent("agentA");
|
||||
var agentB = new RecordingAgent("agentB");
|
||||
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new PrefixingGroupChatManager(agents, "[broadcast] ") { MaximumIterationCount = 2 })
|
||||
.AddParticipants(agentA, agentB)
|
||||
.Build();
|
||||
|
||||
const string UserInput = "hello";
|
||||
await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
|
||||
// Turn 1: agentA's buffer contains only the initial broadcast, which UpdateHistoryAsync
|
||||
// prefixed.
|
||||
Assert.Equal(["[broadcast] hello"], agentA.Invocations[0]);
|
||||
|
||||
// Turn 2: agentB received both the initial broadcast and agentA's response — both passed
|
||||
// through UpdateHistoryAsync before being broadcast.
|
||||
Assert.Equal(["[broadcast] hello", "[broadcast] agentA"], agentB.Invocations[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildGroupChat_CheckpointResumeMidConversation_PreservesIterationCursorAndBroadcastExclusionAsync()
|
||||
{
|
||||
const string UserInput = "hello";
|
||||
const int MaxIterations = 6;
|
||||
|
||||
// --- Baseline: run the full conversation under checkpointing and capture every checkpoint
|
||||
// plus the final transcript. The same workflow + agents are reused for the resume,
|
||||
// because the runner enforces workflow-shape compatibility on ResumeStreamingAsync. ---
|
||||
BaselineRunResult baseline = await RunGroupChatBaselineAsync(UserInput, MaxIterations);
|
||||
|
||||
// We need at least one mid-conversation checkpoint to resume from. The baseline produces a
|
||||
// checkpoint per superstep, which for MaxIterations=6 yields many; we pick a checkpoint
|
||||
// captured roughly midway so the resumed run still has work to do.
|
||||
Assert.True(baseline.Checkpoints.Count >= 5,
|
||||
$"expected at least 5 checkpoints in the baseline, got {baseline.Checkpoints.Count}");
|
||||
|
||||
int midIndex = baseline.Checkpoints.Count / 2;
|
||||
CheckpointInfo midCheckpoint = baseline.Checkpoints[midIndex];
|
||||
|
||||
// Snapshot per-agent invocation counts before the resume so we can isolate the invocations
|
||||
// produced after the checkpoint is restored.
|
||||
int aPreCount = baseline.AgentA.Invocations.Count;
|
||||
int bPreCount = baseline.AgentB.Invocations.Count;
|
||||
int cPreCount = baseline.AgentC.Invocations.Count;
|
||||
|
||||
// --- Resume the same workflow from the mid-conversation checkpoint. ---
|
||||
List<ChatMessage>? resumedResult = null;
|
||||
await using (StreamingRun resumed = await baseline.Environment
|
||||
.ResumeStreamingAsync(baseline.Workflow, midCheckpoint))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
if (evt is WorkflowOutputEvent o)
|
||||
{
|
||||
resumedResult = o.As<List<ChatMessage>>();
|
||||
}
|
||||
else if (evt is WorkflowErrorEvent err)
|
||||
{
|
||||
Assert.Fail($"Resumed workflow failed: {err.Exception}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (1) Iteration-count continuity: the resumed run terminates with exactly the same number
|
||||
// of turns the baseline produced — proves IterationCount was rehydrated and the manager
|
||||
// honored MaximumIterationCount across the boundary.
|
||||
Assert.NotNull(resumedResult);
|
||||
Assert.Equal(baseline.Result.Count, resumedResult!.Count);
|
||||
|
||||
// (2) Next-speaker consistency: the full transcript (initial input + every speaker's turn,
|
||||
// in order) matches the baseline — proves the round-robin cursor was restored.
|
||||
List<string?> baselineTranscript = [.. baseline.Result.Select(m => m.Text)];
|
||||
List<string?> resumedTranscript = [.. resumedResult.Select(m => m.Text)];
|
||||
Assert.Equal(baselineTranscript, resumedTranscript);
|
||||
|
||||
// (3) Broadcast exclusion holds across resume: a RecordingAgent's response text is just its
|
||||
// own Name. Examine only the invocations recorded after the resume. If the host failed
|
||||
// to exclude the current speaker from its post-resume broadcasts, an agent's next
|
||||
// invocation buffer would contain its own previously produced response. Asserting that
|
||||
// no post-resume invocation input contains the invoking agent's own name proves the
|
||||
// exclusion was preserved through checkpoint+restore.
|
||||
AssertPostResumeBroadcastExclusion(baseline.AgentA, aPreCount);
|
||||
AssertPostResumeBroadcastExclusion(baseline.AgentB, bPreCount);
|
||||
AssertPostResumeBroadcastExclusion(baseline.AgentC, cPreCount);
|
||||
|
||||
// Sanity: at least one agent was actually invoked after the resume; otherwise the test
|
||||
// would trivially pass even if the host stopped scheduling turns after restore.
|
||||
int totalPost = baseline.AgentA.Invocations.Count - aPreCount
|
||||
+ (baseline.AgentB.Invocations.Count - bPreCount)
|
||||
+ (baseline.AgentC.Invocations.Count - cPreCount);
|
||||
Assert.True(totalPost > 0, "at least one agent should be invoked after resuming from the mid-conversation checkpoint");
|
||||
|
||||
static void AssertPostResumeBroadcastExclusion(RecordingAgent agent, int preCount)
|
||||
{
|
||||
for (int i = preCount; i < agent.Invocations.Count; i++)
|
||||
{
|
||||
Assert.DoesNotContain(agent.Name, agent.Invocations[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BaselineRunResult(
|
||||
Workflow Workflow,
|
||||
InProcessExecutionEnvironment Environment,
|
||||
RecordingAgent AgentA,
|
||||
RecordingAgent AgentB,
|
||||
RecordingAgent AgentC,
|
||||
List<ChatMessage> Result,
|
||||
List<CheckpointInfo> Checkpoints,
|
||||
CheckpointManager CheckpointManager);
|
||||
|
||||
private static async Task<BaselineRunResult> RunGroupChatBaselineAsync(string userInput, int maxIterations)
|
||||
{
|
||||
var agentA = new RecordingAgent("agentA");
|
||||
var agentB = new RecordingAgent("agentB");
|
||||
var agentC = new RecordingAgent("agentC");
|
||||
|
||||
Workflow workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
|
||||
.AddParticipants(agentA, agentB, agentC)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointMgr = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = ExecutionEnvironment.InProcess_Lockstep
|
||||
.ToWorkflowExecutionEnvironment()
|
||||
.WithCheckpointing(checkpointMgr);
|
||||
|
||||
List<CheckpointInfo> checkpoints = [];
|
||||
List<ChatMessage>? finalResult = null;
|
||||
|
||||
await using (StreamingRun run = await env.OpenStreamingAsync(workflow))
|
||||
{
|
||||
await run.TrySendMessageAsync(new List<ChatMessage> { new(ChatRole.User, userInput) });
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case SuperStepCompletedEvent step when step.CompletionInfo?.Checkpoint is { } cp:
|
||||
checkpoints.Add(cp);
|
||||
break;
|
||||
case WorkflowOutputEvent o:
|
||||
finalResult = o.As<List<ChatMessage>>();
|
||||
break;
|
||||
case WorkflowErrorEvent err:
|
||||
Assert.Fail($"Baseline workflow failed: {err.Exception}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.NotNull(finalResult);
|
||||
return new BaselineRunResult(workflow, env, agentA, agentB, agentC, finalResult!, checkpoints, checkpointMgr);
|
||||
}
|
||||
|
||||
private sealed class PrefixingGroupChatManager(IReadOnlyList<AIAgent> agents, string prefix) : RoundRobinGroupChatManager(agents)
|
||||
{
|
||||
protected internal override ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
|
||||
IReadOnlyList<ChatMessage> history,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ChatMessage> prefixed =
|
||||
history.Select(m => new ChatMessage(m.Role, $"{prefix}{m.Text}") { AuthorName = m.AuthorName });
|
||||
|
||||
return new(prefixed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestration-level tests for <see cref="AgentWorkflowBuilder.CreateGroupChatBuilderWith"/> covering
|
||||
/// <see cref="FunctionCallContent"/> and <see cref="ToolApprovalRequestContent"/> behavior across
|
||||
/// real <see cref="ChatClientAgent"/> participants. These tests parallel the equivalents in
|
||||
/// <see cref="HandoffOrchestrationTests"/> to ensure that the broadcast-based group chat host
|
||||
/// (each participant maintains its own per-agent session via <see cref="Specialized.AIAgentHostExecutor"/>;
|
||||
/// only the speaker receives a <see cref="TurnToken"/>; messages are broadcast to every other
|
||||
/// participant) preserves the same HITL semantics as the handoff path.
|
||||
/// </summary>
|
||||
public class GroupChatOrchestrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end tool-approval checkpoint/resume scenario through a <see cref="RoundRobinGroupChatManager"/>
|
||||
/// with a single participant. Mirrors the maximal repro added in PR #5952 (Track A2 in
|
||||
/// <c>docs/working/issue-5350-root-cause-validation-plan.md</c>): a <see cref="ChatClientAgent"/>
|
||||
/// over a mock chat client emits a <see cref="FunctionCallContent"/> for an
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>, the runtime surfaces a
|
||||
/// <see cref="ToolApprovalRequestContent"/> as an external <see cref="RequestInfoEvent"/>, the test
|
||||
/// checkpoints while the request is pending, resumes from a fresh handle, asserts that the
|
||||
/// resumed <c>TARC.ToolCall</c> is still a <see cref="FunctionCallContent"/>, sends an
|
||||
/// approval response, and verifies that the wrapped <see cref="AIFunction"/> is invoked
|
||||
/// exactly once and the workflow completes without errors.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GroupChat_ToolApproval_JsonCheckpointResume_PreservesFunctionCallContentAndInvokesToolAsync()
|
||||
{
|
||||
ApprovalHarness harness = new();
|
||||
Workflow workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 4 })
|
||||
.AddParticipants(harness.Agent)
|
||||
.Build();
|
||||
|
||||
await RunCheckpointedApprovalRoundTripAsync(
|
||||
workflow,
|
||||
harness,
|
||||
CheckpointManager.CreateJson(new InMemoryJsonStore()),
|
||||
scenarioName: "GroupChat (round-robin, single participant)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round-robin group chat with two participants. The first participant exposes an
|
||||
/// <see cref="ApprovalRequiredAIFunction"/> and emits a <see cref="FunctionCallContent"/> for it on
|
||||
/// its first turn. The test denies the approval and asserts that the conversation continues:
|
||||
/// the first agent runs once more (the FICC denial branch produces a final assistant message),
|
||||
/// then the host broadcasts that message and selects the second agent, which produces its own
|
||||
/// reply. This mirrors <c>Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedByThirdAgentAsync</c>
|
||||
/// but on the group-chat path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GroupChat_ToolApproval_DeniedResponse_ConversationContinuesAsync()
|
||||
{
|
||||
int approvalToolCallCount = 0;
|
||||
|
||||
const string ApprovalCallId = "approve_call_1";
|
||||
const string ApprovalToolName = "DoSomethingPrivileged";
|
||||
|
||||
AIFunction approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
|
||||
() =>
|
||||
{
|
||||
Interlocked.Increment(ref approvalToolCallCount);
|
||||
return "tool result";
|
||||
},
|
||||
name: ApprovalToolName,
|
||||
description: "Performs a privileged action"));
|
||||
|
||||
int agent1CallCount = 0;
|
||||
var agent1 = new ChatClientAgent(
|
||||
new MockChatClient((messages, options) =>
|
||||
{
|
||||
int call = Interlocked.Increment(ref agent1CallCount);
|
||||
return call switch
|
||||
{
|
||||
1 => new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||
[new FunctionCallContent(ApprovalCallId, ApprovalToolName)])),
|
||||
_ => new ChatResponse(new ChatMessage(ChatRole.Assistant, "agent1 final response")),
|
||||
};
|
||||
}),
|
||||
instructions: "You are agent1.",
|
||||
name: "agent1",
|
||||
tools: [approvalTool]);
|
||||
|
||||
int agent2CallCount = 0;
|
||||
var agent2 = new ChatClientAgent(
|
||||
new MockChatClient((messages, options) =>
|
||||
{
|
||||
Interlocked.Increment(ref agent2CallCount);
|
||||
return new ChatResponse(new ChatMessage(ChatRole.Assistant, "agent2 reply"));
|
||||
}),
|
||||
instructions: "You are agent2.",
|
||||
name: "agent2");
|
||||
|
||||
Workflow workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
|
||||
.AddParticipants(agent1, agent2)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = InProcessExecution.OffThread.WithCheckpointing(checkpointManager);
|
||||
|
||||
ExternalRequest? pendingRequest = null;
|
||||
CheckpointInfo? lastCheckpoint = null;
|
||||
List<WorkflowEvent> firstRunEvents = [];
|
||||
|
||||
await using (StreamingRun firstRun = await env.RunStreamingAsync(workflow, new List<ChatMessage> { new(ChatRole.User, "hello") }))
|
||||
{
|
||||
(await firstRun.TrySendMessageAsync(new TurnToken(emitEvents: false)))
|
||||
.Should().BeTrue();
|
||||
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
|
||||
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
firstRunEvents.Add(evt);
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
pendingRequest ??= requestInfo.Request;
|
||||
}
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
lastCheckpoint = cp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingRequest.Should().NotBeNull("agent1 should have surfaced an approval request for the privileged tool");
|
||||
firstRunEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty();
|
||||
firstRunEvents.OfType<ExecutorFailedEvent>().Should().BeEmpty();
|
||||
approvalToolCallCount.Should().Be(0, "the tool must not be invoked before approval is granted");
|
||||
|
||||
ToolApprovalRequestContent approvalRequest =
|
||||
pendingRequest!.Data.As<ToolApprovalRequestContent>().Should().NotBeNull()
|
||||
.And.Subject.As<ToolApprovalRequestContent>();
|
||||
approvalRequest.ToolCall.Should().BeOfType<FunctionCallContent>();
|
||||
((FunctionCallContent)approvalRequest.ToolCall).Name.Should().Be(ApprovalToolName);
|
||||
|
||||
// Deny the request and continue the conversation.
|
||||
ExternalResponse denial = pendingRequest.CreateResponse(approvalRequest.CreateResponse(approved: false, reason: "Denied"));
|
||||
|
||||
List<WorkflowEvent> secondRunEvents = [];
|
||||
List<ChatMessage>? finalOutput = null;
|
||||
await using (StreamingRun resumed = await env.ResumeStreamingAsync(workflow, lastCheckpoint!))
|
||||
{
|
||||
await resumed.SendResponseAsync(denial);
|
||||
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
secondRunEvents.Add(evt);
|
||||
if (evt is WorkflowOutputEvent outputEvt)
|
||||
{
|
||||
finalOutput = outputEvt.As<List<ChatMessage>>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
secondRunEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
|
||||
"denying the approval should not surface any workflow errors");
|
||||
secondRunEvents.OfType<ExecutorFailedEvent>().Should().BeEmpty(
|
||||
"denying the approval should not raise executor failures (regression guard for the GroupChat duplicate-key bug pinned in PR #5952's A2 test before the broadcast refactor)");
|
||||
|
||||
approvalToolCallCount.Should().Be(0, "the tool must not be invoked after denial");
|
||||
agent1CallCount.Should().BeGreaterThanOrEqualTo(2, "agent1 should be re-invoked by FICC after the denial to produce a final assistant message");
|
||||
agent2CallCount.Should().Be(1, "agent2 should be the next round-robin speaker and produce its own reply");
|
||||
|
||||
finalOutput.Should().NotBeNull();
|
||||
finalOutput!.Should().Contain(m => m.AuthorName == "agent1");
|
||||
finalOutput.Should().Contain(m => m.AuthorName == "agent2" && m.Text == "agent2 reply");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round-robin group chat with two participants. The first participant declares a
|
||||
/// non-invokable function via <c>AIFunctionFactory.CreateDeclaration</c>,
|
||||
/// causing the function call to be surfaced as an external <see cref="FunctionCallContent"/>
|
||||
/// (<see cref="RequestInfoEvent"/>). The test responds with a <see cref="FunctionResultContent"/>
|
||||
/// and asserts that the conversation continues: the first agent's second invocation produces a
|
||||
/// final assistant message, then the group chat advances to the second agent which produces
|
||||
/// its own reply. This mirrors <c>Handoffs_TwoTransfers_SecondAgentToolCall_ResponseServedByThirdAgentAsync</c>
|
||||
/// but on the group-chat path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GroupChat_FunctionCall_ExternallyResolved_ConversationContinuesAsync()
|
||||
{
|
||||
const string FunctionCallId = "fcc_call_1";
|
||||
const string FunctionName = "FetchExternalData";
|
||||
|
||||
JsonElement schema = AIFunctionFactory.Create(() => true).JsonSchema;
|
||||
AIFunctionDeclaration declaration = AIFunctionFactory.CreateDeclaration(FunctionName, "Fetches external data", schema);
|
||||
|
||||
int agent1CallCount = 0;
|
||||
var agent1 = new ChatClientAgent(
|
||||
new MockChatClient((messages, options) =>
|
||||
{
|
||||
int call = Interlocked.Increment(ref agent1CallCount);
|
||||
return call switch
|
||||
{
|
||||
1 => new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||
[new FunctionCallContent(FunctionCallId, FunctionName)])),
|
||||
_ => new ChatResponse(new ChatMessage(ChatRole.Assistant, "agent1 final response")),
|
||||
};
|
||||
}),
|
||||
instructions: "You are agent1.",
|
||||
name: "agent1",
|
||||
tools: [declaration]);
|
||||
|
||||
int agent2CallCount = 0;
|
||||
var agent2 = new ChatClientAgent(
|
||||
new MockChatClient((messages, options) =>
|
||||
{
|
||||
Interlocked.Increment(ref agent2CallCount);
|
||||
return new ChatResponse(new ChatMessage(ChatRole.Assistant, "agent2 reply"));
|
||||
}),
|
||||
instructions: "You are agent2.",
|
||||
name: "agent2");
|
||||
|
||||
Workflow workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
|
||||
.AddParticipants(agent1, agent2)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = InProcessExecution.OffThread.WithCheckpointing(checkpointManager);
|
||||
|
||||
ExternalRequest? pendingRequest = null;
|
||||
CheckpointInfo? lastCheckpoint = null;
|
||||
|
||||
await using (StreamingRun firstRun = await env.RunStreamingAsync(workflow, new List<ChatMessage> { new(ChatRole.User, "hello") }))
|
||||
{
|
||||
(await firstRun.TrySendMessageAsync(new TurnToken(emitEvents: false)))
|
||||
.Should().BeTrue();
|
||||
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
|
||||
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
pendingRequest ??= requestInfo.Request;
|
||||
}
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
lastCheckpoint = cp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingRequest.Should().NotBeNull("agent1 should have surfaced a FunctionCallContent for the declaration-only tool");
|
||||
|
||||
FunctionCallContent functionCall =
|
||||
pendingRequest!.Data.As<FunctionCallContent>().Should().NotBeNull()
|
||||
.And.Subject.As<FunctionCallContent>();
|
||||
functionCall.Name.Should().Be(FunctionName);
|
||||
functionCall.CallId.Should().EndWith(FunctionCallId,
|
||||
"the workflow rewrites the CallId with an executor-scoped prefix, but should preserve the original tail");
|
||||
|
||||
// Respond with a function result and let the conversation continue.
|
||||
ExternalResponse response = pendingRequest.CreateResponse(new FunctionResultContent(functionCall.CallId, "external-data-payload"));
|
||||
|
||||
List<WorkflowEvent> resumeEvents = [];
|
||||
List<ChatMessage>? finalOutput = null;
|
||||
await using (StreamingRun resumed = await env.ResumeStreamingAsync(workflow, lastCheckpoint!))
|
||||
{
|
||||
await resumed.SendResponseAsync(response);
|
||||
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
resumeEvents.Add(evt);
|
||||
if (evt is WorkflowOutputEvent outputEvt)
|
||||
{
|
||||
finalOutput = outputEvt.As<List<ChatMessage>>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resumeEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty();
|
||||
resumeEvents.OfType<ExecutorFailedEvent>().Should().BeEmpty();
|
||||
|
||||
agent1CallCount.Should().BeGreaterThanOrEqualTo(2, "agent1 should be re-invoked once the externally-resolved function result is delivered");
|
||||
agent2CallCount.Should().Be(1, "agent2 should be the next round-robin speaker after agent1 finishes");
|
||||
|
||||
finalOutput.Should().NotBeNull();
|
||||
finalOutput!.Should().Contain(m => m.AuthorName == "agent1");
|
||||
finalOutput.Should().Contain(m => m.AuthorName == "agent2" && m.Text == "agent2 reply");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared end-to-end driver for the approval checkpoint/resume scenario; modelled on the
|
||||
/// <c>RunReproAsync</c> helper from PR #5952. Runs the workflow until an approval request is
|
||||
/// pending, captures the latest checkpoint, disposes the run, resumes from a fresh handle,
|
||||
/// asserts the resumed payload still carries a <see cref="FunctionCallContent"/>, sends an
|
||||
/// approval response, and asserts the wrapped tool is invoked exactly once and the workflow
|
||||
/// finishes without errors.
|
||||
/// </summary>
|
||||
private static async Task RunCheckpointedApprovalRoundTripAsync(
|
||||
Workflow workflow,
|
||||
ApprovalHarness harness,
|
||||
CheckpointManager checkpointManager,
|
||||
string scenarioName)
|
||||
{
|
||||
InProcessExecutionEnvironment env = InProcessExecution.OffThread;
|
||||
List<ChatMessage> inputMessages = [new(ChatRole.User, "What's the weather in Amsterdam?")];
|
||||
|
||||
ExternalRequest? firstRunRequest = null;
|
||||
CheckpointInfo? checkpoint = null;
|
||||
|
||||
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, inputMessages))
|
||||
{
|
||||
(await firstRun.TrySendMessageAsync(new TurnToken(emitEvents: false)))
|
||||
.Should().BeTrue($"[{scenarioName}] the workflow should accept a TurnToken");
|
||||
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
|
||||
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
firstRunRequest ??= requestInfo.Request;
|
||||
}
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
checkpoint = cp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstRunRequest.Should().NotBeNull(
|
||||
$"[{scenarioName}] the ChatClientAgent + FICC pipeline should surface the approval request as a workflow RequestInfoEvent");
|
||||
checkpoint.Should().NotBeNull(
|
||||
$"[{scenarioName}] a checkpoint should have been produced while the approval request was pending");
|
||||
harness.ChatCallCount.Should().Be(1, $"[{scenarioName}] the mock chat client should have been called exactly once before approval was requested");
|
||||
harness.InvocationCount.Should().Be(0, $"[{scenarioName}] the underlying tool must NOT have been invoked before approval was granted");
|
||||
|
||||
ToolApprovalRequestContent? preCheckpoint = firstRunRequest!.Data.As<ToolApprovalRequestContent>();
|
||||
preCheckpoint.Should().NotBeNull($"[{scenarioName}] the pending external request should carry a ToolApprovalRequestContent payload");
|
||||
preCheckpoint!.ToolCall.Should().BeOfType<FunctionCallContent>(
|
||||
$"[{scenarioName}] the pre-checkpoint pending request payload must already be a FunctionCallContent");
|
||||
|
||||
// Resume on a fresh handle and capture the re-emitted approval request.
|
||||
ExternalRequest? resumedRequest = null;
|
||||
List<WorkflowEvent> postResumeEvents = [];
|
||||
|
||||
await using (StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingAsync(workflow, checkpoint!))
|
||||
{
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
resumedRequest ??= requestInfo.Request;
|
||||
}
|
||||
}
|
||||
|
||||
resumedRequest.Should().NotBeNull($"[{scenarioName}] the resumed workflow should re-emit the pending approval RequestInfoEvent");
|
||||
|
||||
ToolApprovalRequestContent? postResume = resumedRequest!.Data.As<ToolApprovalRequestContent>();
|
||||
postResume.Should().NotBeNull(
|
||||
$"[{scenarioName}] ExternalRequest.Data.As<ToolApprovalRequestContent>() should materialize the payload after JSON-checkpoint resume");
|
||||
postResume!.ToolCall.Should().NotBeNull($"[{scenarioName}] the resumed TARC must carry its ToolCall");
|
||||
postResume.ToolCall.Should().BeOfType<FunctionCallContent>(
|
||||
$"[{scenarioName}] after CheckpointManager.CreateJson round-trip via ResumeStreamingAsync, " +
|
||||
"ToolApprovalRequestContent.ToolCall must still be a FunctionCallContent so that " +
|
||||
"FunctionInvokingChatClient's pattern match (`tarc.ToolCall is FunctionCallContent`) continues to fire.");
|
||||
|
||||
ToolApprovalResponseContent approvalResponse = postResume.CreateResponse(approved: true);
|
||||
await resumed.SendResponseAsync(resumedRequest.CreateResponse(approvalResponse));
|
||||
|
||||
using CancellationTokenSource cts2 = new(TimeSpan.FromSeconds(30));
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts2.Token))
|
||||
{
|
||||
postResumeEvents.Add(evt);
|
||||
}
|
||||
}
|
||||
|
||||
harness.InvocationCount.Should().Be(1,
|
||||
$"[{scenarioName}] approving the request should cause FunctionInvokingChatClient to invoke the wrapped AIFunction exactly once");
|
||||
postResumeEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
|
||||
$"[{scenarioName}] no workflow errors should be raised when responding to the resumed approval request");
|
||||
postResumeEvents.OfType<ExecutorFailedEvent>().Should().BeEmpty(
|
||||
$"[{scenarioName}] no executor failures should be raised when responding to the resumed approval request " +
|
||||
"(regression guard: pre-broadcast-refactor this test was the `Track A2` repro in PR #5952 which surfaced a " +
|
||||
"duplicate-key ArgumentException out of FunctionInvokingChatClient.ExtractAndRemoveApprovalRequestsAndResponses).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundles a <see cref="ChatClientAgent"/> with a counting <see cref="ApprovalRequiredAIFunction"/>
|
||||
/// tool and a <see cref="MockChatClient"/> that emits a function call on the first chat turn
|
||||
/// and a final assistant text on subsequent turns (after FICC has processed the approval
|
||||
/// and appended a <see cref="FunctionResultContent"/>).
|
||||
/// </summary>
|
||||
private sealed class ApprovalHarness
|
||||
{
|
||||
public const string ToolName = "GetWeather";
|
||||
public const string ToolResultText = "Sunny, 22°C";
|
||||
public const string ToolCallId = "call-1";
|
||||
public const string FinalAssistantText = "The weather in Amsterdam is sunny and 22°C.";
|
||||
|
||||
private int _invocationCount;
|
||||
private int _chatCallIndex;
|
||||
|
||||
public int InvocationCount => Volatile.Read(ref this._invocationCount);
|
||||
public int ChatCallCount => Volatile.Read(ref this._chatCallIndex);
|
||||
|
||||
public ChatClientAgent Agent { get; }
|
||||
|
||||
public ApprovalHarness()
|
||||
{
|
||||
AIFunction underlyingTool = AIFunctionFactory.Create(
|
||||
([Description("City to look up")] string city) =>
|
||||
{
|
||||
Interlocked.Increment(ref this._invocationCount);
|
||||
return ToolResultText;
|
||||
},
|
||||
name: ToolName,
|
||||
description: "Gets the weather for the given city");
|
||||
|
||||
ApprovalRequiredAIFunction approvalTool = new(underlyingTool);
|
||||
|
||||
MockChatClient mockChatClient = new((messages, options) =>
|
||||
{
|
||||
int index = Interlocked.Increment(ref this._chatCallIndex) - 1;
|
||||
return index switch
|
||||
{
|
||||
0 => new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||
[new FunctionCallContent(
|
||||
callId: ToolCallId,
|
||||
name: ToolName,
|
||||
arguments: new Dictionary<string, object?> { ["city"] = "Amsterdam" })])),
|
||||
_ => new ChatResponse(new ChatMessage(ChatRole.Assistant, FinalAssistantText)),
|
||||
};
|
||||
});
|
||||
|
||||
this.Agent = new ChatClientAgent(
|
||||
mockChatClient,
|
||||
instructions: "You are a weather agent.",
|
||||
name: "WeatherAgent",
|
||||
tools: [approvalTool]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IChatClient"/> stub for orchestration tests; delegates each call to a
|
||||
/// caller-supplied factory.
|
||||
/// </summary>
|
||||
private sealed class MockChatClient(Func<IEnumerable<ChatMessage>, ChatOptions?, ChatResponse> responseFactory) : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(responseFactory(messages, options));
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ChatResponse response = await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
|
||||
foreach (ChatResponseUpdate update in response.ToChatResponseUpdates())
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -788,6 +788,34 @@ public class JsonSerializationTests
|
||||
result.IsTakingTurn.Should().Be(prototype.IsTakingTurn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_GroupChatManagerState_JsonRoundtrip()
|
||||
{
|
||||
// Arrange
|
||||
GroupChatManagerState prototype = new(IterationCount: 7);
|
||||
|
||||
// Act
|
||||
GroupChatManagerState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(prototype);
|
||||
result.IterationCount.Should().Be(prototype.IterationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_RoundRobinGroupChatManagerState_JsonRoundtrip()
|
||||
{
|
||||
// Arrange
|
||||
RoundRobinGroupChatManagerState prototype = new(NextIndex: 3);
|
||||
|
||||
// Act
|
||||
RoundRobinGroupChatManagerState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(prototype);
|
||||
result.NextIndex.Should().Be(prototype.NextIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails
|
||||
/// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue.
|
||||
|
||||
+51
@@ -136,4 +136,55 @@ public class RoundRobinGroupChatManagerTests
|
||||
FluentActions.Invoking(() => new RoundRobinGroupChatManager([]))
|
||||
.Should().Throw<System.ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundRobinGroupChat_CheckpointRoundTrip_PreservesIterationCountAndCursorAsync()
|
||||
{
|
||||
TestEchoAgent agent1 = new(id: "agent1");
|
||||
TestEchoAgent agent2 = new(id: "agent2");
|
||||
TestEchoAgent agent3 = new(id: "agent3");
|
||||
List<AIAgent> agents = [agent1, agent2, agent3];
|
||||
List<ChatMessage> history = [];
|
||||
|
||||
TestRunState sharedState = new();
|
||||
TestWorkflowContext sourceContext = new("gcm-host", sharedState);
|
||||
TestWorkflowContext sinkContext = new("gcm-host", sharedState);
|
||||
|
||||
RoundRobinGroupChatManager source = new(agents);
|
||||
await source.SelectNextAgentAsync(history); // cursor -> agent2
|
||||
source.IterationCount = 7;
|
||||
|
||||
await source.CheckpointAsync(sourceContext);
|
||||
|
||||
RoundRobinGroupChatManager restored = new(agents);
|
||||
restored.IterationCount.Should().Be(0, "freshly constructed manager has no iteration count");
|
||||
|
||||
await restored.RestoreCheckpointAsync(sinkContext);
|
||||
|
||||
restored.IterationCount.Should().Be(7, "the base hook must rehydrate IterationCount");
|
||||
|
||||
AIAgent next = await restored.SelectNextAgentAsync(history);
|
||||
next.Should().BeSameAs(agent2, "the round-robin cursor should resume where the source left off");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundRobinGroupChat_RestoreWithoutCheckpoint_DefaultsToZeroStateAsync()
|
||||
{
|
||||
TestEchoAgent agent1 = new(id: "agent1");
|
||||
TestEchoAgent agent2 = new(id: "agent2");
|
||||
List<AIAgent> agents = [agent1, agent2];
|
||||
List<ChatMessage> history = [];
|
||||
|
||||
TestWorkflowContext emptyContext = new("gcm-host");
|
||||
|
||||
RoundRobinGroupChatManager manager = new(agents);
|
||||
manager.IterationCount = 3;
|
||||
await manager.SelectNextAgentAsync(history); // cursor advanced
|
||||
|
||||
await manager.RestoreCheckpointAsync(emptyContext);
|
||||
|
||||
manager.IterationCount.Should().Be(0, "restore from an empty checkpoint should clear IterationCount");
|
||||
AIAgent next = await manager.SelectNextAgentAsync(history);
|
||||
next.Should().BeSameAs(agent1, "restore from an empty checkpoint should reset the cursor to the first agent");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user