// 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;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
///
/// A manager that manages the flow of a group chat.
///
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_";
///
/// Initializes a new instance of the class.
///
protected GroupChatManager() { }
///
/// Gets the number of iterations in the group chat so far.
///
public int IterationCount { get; internal set; }
///
/// Gets or sets the maximum number of iterations allowed.
///
///
/// Each iteration involves a single interaction with a participating agent.
/// The default is 40.
///
public int MaximumIterationCount
{
get;
set => field = Throw.IfLessThan(value, 1);
} = 40;
///
/// Selects the next agent to participate in the group chat based on the provided chat history and team.
///
/// The chat history to consider.
/// The to monitor for cancellation requests.
/// The default is .
/// The next to speak. This agent must be part of the chat.
protected internal abstract ValueTask SelectNextAgentAsync(
IReadOnlyList history,
CancellationToken cancellationToken = default);
///
/// Filters the messages broadcast to participants for the current turn.
///
///
/// Under the broadcast model, each participant maintains its own per-agent session (history)
/// through its . 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 and
/// .
///
/// The new messages about to be broadcast to participants this turn.
/// The to monitor for cancellation requests.
/// The default is .
/// The filtered message list to broadcast.
protected internal virtual ValueTask> UpdateHistoryAsync(
IReadOnlyList history,
CancellationToken cancellationToken = default) =>
new(history);
///
/// Determines whether the group chat should be terminated based on the provided chat history and iteration count.
///
/// The chat history to consider.
/// The to monitor for cancellation requests.
/// The default is .
/// A indicating whether the chat should be terminated.
protected internal virtual ValueTask ShouldTerminateAsync(
IReadOnlyList history,
CancellationToken cancellationToken = default) =>
new(this.MaximumIterationCount is int max && this.IterationCount >= max);
///
/// Resets the state of the manager for a new group chat session.
///
protected internal virtual void Reset()
{
this.IterationCount = 0;
}
///
/// 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).
///
///
///
/// The default implementation is a no-op. Base-class state (currently
/// ) is persisted automatically by the hosting
/// before this method is invoked; subclasses do not
/// need to call base.OnCheckpointingAsync.
///
///
/// The supplied is a wrapper that transparently prefixes every
/// state key with "GroupChatManager_", 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., "next_index") without worrying about collisions.
///
///
/// A wrapped workflow context that scopes state keys to the
/// subclass namespace.
/// The to monitor for cancellation requests.
/// The default is .
protected virtual ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
=> default;
///
/// 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
/// .
///
///
/// The default implementation is a no-op. Base-class state (currently
/// ) is restored automatically by the hosting
/// before this method is invoked; subclasses do not
/// need to call base.OnCheckpointRestoredAsync. The supplied
/// uses the same key-prefixing wrapper as .
///
/// A wrapped workflow context that scopes state keys to the
/// subclass namespace.
/// The to monitor for cancellation requests.
/// The default is .
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(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? 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 ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default)
=> this._inner.ReadStateAsync(this.Wrap(key), scopeName, cancellationToken);
public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
=> this._inner.ReadOrInitStateAsync(this.Wrap(key), initialStateFactory, scopeName, cancellationToken);
public async ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
HashSet 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(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 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