// 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(rawKey, null, scopeName, cancellationToken).ConfigureAwait(false); } } } private string Wrap(string key) => this._prefix + Throw.IfNullOrEmpty(key); }