Nitin Kumar SinghSolutions Architect

Type to search. to move, Enter to open.

    move open esc close
    Deep DiveAI EngineeringPart 17 of 29

    MAF v1 — Group Chat Orchestration (Python + .NET)

    Use a centralized manager to pick speakers each round, compare round-robin and agent-driven strategies, and cap runaway loops.

    Why this chapter

    Sequential, Concurrent, and Handoff all fix who speaks at wiring time: either by order, by parallelism, or by whichever agent decides to hand off next. Group Chat is the shape for the other case: a meeting. Everyone’s at the table; a manager decides who talks next; rounds continue until the work is done or the round cap trips.

    Canonical example in this chapter: Writer drafts a line, Critic points out an issue, Editor produces the polished final. Three agents, one shared conversation, a manager loop that runs until termination.

    Jargon defined inline below: GroupChatBuilder, selection_func, orchestrator_agent, GroupChatManager, RoundRobinGroupChatManager, max_rounds / MaximumIterationCount, termination condition.

    What you’ll build

    • A Writer/Critic/Editor group chat in Python (GroupChatBuilder) and .NET (AgentWorkflowBuilder.CreateGroupChatBuilderWith), where a manager picks the next speaker each round.
    • Two manager strategies side by side: deterministic round-robin, and a prompt-driven manager that asks an LLM who should speak next.
    • A hard round cap (max_rounds / MaximumIterationCount) plus a termination condition, so an iterative loop can’t bill the model forever.
    • The .NET custom GroupChatManager subclass, including the fallback that turns a bad selector reply into a safe round-robin step.

    The concept

    The manager loop

    Every group chat orchestration is the same state machine. The manager observes the conversation, picks the next speaker, runs that agent, appends the response, checks termination, and loops.

    One roundWhen it stopsObserve statefull conversation + round indexSelect next speakerround-robin, prompt or agentRun selected agentappend its responseCheck terminationmax_rounds or conditionEmit final conversationterminate
    One round of a group chat. The manager observes, picks a speaker, runs it, then checks whether to stop — and the long arc back to Observe is the loop that max_rounds bounds.

    One loop, three pluggable points: how to select the next speaker, when to terminate, and what counts as the “full conversation” each speaker sees. Everything else is the same machinery.

    Three manager strategies

    The only thing that changes between “round-robin”, “prompt-driven”, and “agent-driven” is what runs inside Select next speaker. The rest of the loop is identical.

    StrategyDecision sourceKnobs
    Round-robinA pure function over current_round and participant order. No LLM call.max_rounds (Python) / MaximumIterationCount (.NET). Optional termination callback.
    Prompt-drivenA small LLM call inside the manager: “given this transcript, who should speak next?”Same cap knobs, plus the selector prompt itself.
    Agent-drivenA full Agent as the orchestrator. Has instructions, tools, observability.Python: pass orchestrator_agent=. .NET: subclass GroupChatManager.

    Round-robin is deterministic and auditable. Prompt-driven adapts to what the conversation actually needs. Agent-driven is the same as prompt-driven but the “manager” is itself an observable MAF agent, and its traces show up in Aspire the same as any other agent.

    Manager stateThree ways to chooseModelRound-robinPrompt-drivenAgent-drivenManager stateround index + conversationselection_funcor RoundRobinGroupChatManagerSelectNextAgentAsyncinside GroupChatManagerorchestrator Agentinstructions + toolsLLMroster + transcript, JSON backonly this one costs acall
    Three ways to answer the same question — who speaks next. They read the same manager state; only the prompt-driven one spends a model call to decide.

    Same loop, same state input, three different things plugged into the selection slot. The loop doesn’t know which strategy is in use, and that’s the point.

    Jargon

    • GroupChatBuilder (Python): the fluent builder in agent_framework.orchestrations. Accepts participants=, one of selection_func= / orchestrator_agent= / orchestrator=, and termination knobs (max_rounds=, termination_condition=).
    • selection_func: a Python callable (GroupChatState) -> str | Awaitable[str] that returns the name of the next speaker. GroupChatState exposes current_round, participants (ordered name → description), and conversation (full list[Message] so far).
    • orchestrator_agent: a Python alternative to selection_func: hand an entire Agent to the builder and MAF asks it who should speak next. Agent instructions carry the coordination rules.
    • GroupChatManager (.NET): the abstract base for all .NET managers. Override SelectNextAgentAsync (pick the next AIAgent), optionally ShouldTerminateAsync (stop the loop), and UpdateHistoryAsync (filter the history each agent sees). Exposes IterationCount and MaximumIterationCount.
    • RoundRobinGroupChatManager (.NET): built-in manager that walks participants in insertion order. Use it when you want “writer, critic, editor, writer, critic, editor …” and a hard round cap. No prompt-driven built-in exists in .NET, so you subclass GroupChatManager yourself (shown below).
    • max_rounds / MaximumIterationCount: the safety net. If the selector never terminates, the loop ends after this many speaker turns with a GroupChatOrchestrator reached max_rounds=N; forcing completion. log line (Python) or a silent terminate (.NET).
    • Termination condition: a predicate over the conversation. Python: termination_condition=lambda messages: ... on the builder. .NET: override ShouldTerminateAsync.

    Python walkthrough

    Source: python/main.py2. One file; two manager strategies; same three agents.

    Round-robin via selection_func

    The simplest manager. A plain function picks the next speaker from current_round:

    from agent_framework.orchestrations import GroupChatBuilder, GroupChatState
    def round_robin_selector(state: GroupChatState) -> str:
    names = list(state.participants.keys())
    return names[state.current_round % len(names)]
    def build_workflow():
    return (
    GroupChatBuilder(
    participants=[writer(), critic(), editor()],
    selection_func=round_robin_selector,
    max_rounds=3,
    )
    .build()
    )

    Worth staring at:

    • GroupChatState gives you everything you need. state.participants is an OrderedDict[str, str] keyed by agent name; state.current_round is a zero-based index; state.conversation is the full list[Message] so far. No hidden state, nothing async required.
    • max_rounds=3 is a hard cap. The round-robin selector above never terminates on its own; it would cycle forever. The cap is what stops the loop. Ch17 will add a termination_condition callback for early termination.
    • No decorators, no executor wiring. Compare to Chapter 09’s manual fan-out graph: GroupChatBuilder generates the same Pregel shape internally, exposing only the speaker-selection knob.

    Prompt-driven via orchestrator_agent

    Swap the selection function for a full Agent and MAF does the “who speaks next” LLM call for you:

    def prompt_driven_orchestrator() -> Agent:
    return Agent(
    _default_client(),
    name="orchestrator",
    description="Coordinates the Writer/Critic/Editor group chat.",
    instructions=(
    "You coordinate a Writer/Critic/Editor group chat about marketing copy.\n"
    "- Start with the Writer so there is a draft to react to.\n"
    "- Invite the Critic after the Writer has produced a draft.\n"
    "- Invite the Editor only after both Writer and Critic have spoken.\n"
    "- Stop once the Editor has produced a polished final line."
    ),
    )
    workflow = (
    GroupChatBuilder(
    participants=[writer(), critic(), editor()],
    orchestrator_agent=prompt_driven_orchestrator(),
    max_rounds=4, # safety net; the orchestrator may finish earlier
    )
    .build()
    )

    The orchestrator agent is a regular MAF Agent: observed in Aspire, same tool surface, same session management. The only thing special about it is that MAF passes it the roster and conversation each round and interprets its answer as the name of the next speaker.

    Running

    Terminal window
    uv run python tutorials/15-group-chat-orchestration/python/main.py "slogan for a coffee shop"
    # Topic: slogan for a coffee shop
    # Manager: round-robin
    #
    # writer Brewed for You, Sipped with Joy.
    # critic Be more specific to what makes your coffee shop unique.
    # editor Crafting Community, One Cup at a Time.
    uv run python tutorials/15-group-chat-orchestration/python/main.py "slogan for a bookstore" prompt
    # Topic: slogan for a bookstore
    # Manager: prompt
    #
    # writer Where every page begins a new adventure.
    # critic Clarify what makes your bookstore unique ...
    # editor Discover stories that make our shelves unforgettable.

    .NET walkthrough

    Source: dotnet/Program.cs3. Same three agents. Round-robin uses the built-in manager; prompt-driven subclasses GroupChatManager and calls an IChatClient inside SelectNextAgentAsync.

    Round-robin via RoundRobinGroupChatManager

    AIAgent writer = chatClient.AsAIAgent(instructions: WriterInstructions, name: "writer");
    AIAgent critic = chatClient.AsAIAgent(instructions: CriticInstructions, name: "critic");
    AIAgent editor = chatClient.AsAIAgent(instructions: EditorInstructions, name: "editor");
    Workflow workflow = AgentWorkflowBuilder
    .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents)
    {
    MaximumIterationCount = 3,
    })
    .AddParticipants(writer, critic, editor)
    .Build();

    CreateGroupChatBuilderWith takes a manager factory (Func<IReadOnlyList<AIAgent>, GroupChatManager>) so the manager receives the participant list after the builder resolves it. MaximumIterationCount caps the loop, the .NET equivalent of Python’s max_rounds. The default is 40, which is almost never what you want.

    Group chat workflows in .NET wait for an explicit turn token before dispatching, the same pattern as Chapter 13’s concurrent builder:

    var messages = new List<ChatMessage> { new(ChatRole.User, topic) };
    await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, messages);
    await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
    await foreach (WorkflowEvent evt in run.WatchStreamAsync())
    {
    if (evt is AgentResponseUpdateEvent update)
    {
    // Streaming token chunks; buffer by ExecutorId to print one line per speaker.
    Console.Write(update.Update.Text);
    }
    else if (evt is WorkflowOutputEvent output && output.Data is List<ChatMessage> final)
    {
    // The full conversation, one ChatMessage per speaker turn.
    }
    }

    Prompt-driven via a custom GroupChatManager

    .NET has no PromptDrivenGroupChatManager type; you subclass GroupChatManager yourself. That turns out to be three overrides and an LLM call:

    private sealed class PromptDrivenManager : GroupChatManager
    {
    private readonly IReadOnlyList<AIAgent> _agents;
    private readonly IChatClient _selectorClient;
    public PromptDrivenManager(IReadOnlyList<AIAgent> agents, IChatClient selectorClient)
    {
    _agents = agents;
    _selectorClient = selectorClient;
    }
    protected override async ValueTask<AIAgent> SelectNextAgentAsync(
    IReadOnlyList<ChatMessage> history,
    CancellationToken cancellationToken = default)
    {
    string roster = string.Join(", ", _agents.Select(a => a.Name));
    string transcript = string.Join("\n", history.Select(m =>
    $"[{m.AuthorName ?? m.Role.Value}] {m.Text}"));
    string prompt =
    $"You coordinate a Writer/Critic/Editor group chat.\n" +
    $"Available speakers: {roster}.\n" +
    "Pick the single best next speaker.\n" +
    "Reply with ONLY a JSON object: {\"next\": \"<name>\"}.\n\n" +
    $"Conversation so far:\n{transcript}";
    ChatResponse response = await _selectorClient.GetResponseAsync(
    new List<ChatMessage> { new(ChatRole.System, prompt) },
    new ChatOptions { Temperature = 0 },
    cancellationToken);
    string? name = ExtractNameFromJson(response.Text.Trim());
    AIAgent? match = _agents.FirstOrDefault(a =>
    string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
    // Safe fallback: round-robin by iteration if the LLM returned junk.
    return match ?? _agents[(int)(IterationCount % _agents.Count)];
    }
    protected override ValueTask<bool> ShouldTerminateAsync(
    IReadOnlyList<ChatMessage> history,
    CancellationToken cancellationToken = default) =>
    ValueTask.FromResult(history.Any(m =>
    string.Equals(m.AuthorName, "editor", StringComparison.OrdinalIgnoreCase)));
    }

    Practical notes on this pattern:

    • Use IChatClient, not OpenAI.Chat.ChatClient. IChatClient is the provider-agnostic abstraction every MAF agent wraps; calling it from inside a manager avoids binary-coupling to any one provider’s SDK (and avoids the ChatCompletionOptions ABI drift between Microsoft.Extensions.AI.OpenAI versions). Get one via chatClient.AsIChatClient().
    • Always fall back. An LLM that returns an unknown speaker name crashes the workflow. The _agents[IterationCount % _agents.Count] fallback above turns a bad selector reply into a predictable round-robin step instead of an exception.
    • ShouldTerminateAsync is the graceful exit. When it returns true, the workflow emits the final conversation and stops. MaximumIterationCount is the safety net for selectors that never reach the “done” state.

    Running

    Terminal window
    cd tutorials/15-group-chat-orchestration/dotnet
    dotnet build
    dotnet run -- "slogan for a bookstore"
    # Topic: slogan for a bookstore
    # Manager: round-robin
    #
    # [writer] Turning pages, sparking imaginations.
    # [critic] Consider adding a reference to community or discovery to make it distinctive.
    # [editor] Turning pages, sparking imaginations, building community.
    dotnet run -- "slogan for a coffee shop" prompt
    # Topic: slogan for a coffee shop
    # Manager: prompt
    #
    # [writer] Brewed Fresh, Shared Warmth.
    # [critic] Consider making the connection between coffee and community clearer.
    # [editor] Brewing Community, One Cup at a Time.

    Side-by-side: Python vs .NET

    AspectPython.NET
    Builder entry pointGroupChatBuilder(participants=..., selection_func=...)AgentWorkflowBuilder.CreateGroupChatBuilderWith(managerFactory).AddParticipants(...).Build()
    Round-robin strategyselection_func using state.current_roundRoundRobinGroupChatManager(agents)
    Prompt-driven strategyorchestrator_agent=<Agent> (no custom code)Subclass GroupChatManager, call IChatClient in SelectNextAgentAsync
    Agent-driven strategySame orchestrator_agent=, an Agent with tools/instructionsSame: a GroupChatManager subclass can hold any state, including an AIAgent
    Max roundsmax_rounds=N on the builderMaximumIterationCount = N on the manager instance
    Termination conditiontermination_condition=lambda messages: ...Override ShouldTerminateAsync
    Per-turn eventexecutor_completed with list[AgentExecutorResponse]AgentResponseUpdateEvent (streaming) + WorkflowOutputEvent final
    Kicking off the runworkflow.run(topic, stream=True)RunStreamingAsync(...) + TrySendMessageAsync(new TurnToken(emitEvents: true))

    The shape is identical. Python’s orchestrator_agent slot just hides the subclassing that .NET makes you write explicitly: same outcome, different surface.

    Gotchas

    • Selector output must match a participant name exactly. Python compares by the agent’s name attribute; .NET compares AIAgent.Name. An LLM that returns "Writer" instead of "writer" will fail the lookup. Normalize casing and always have a fallback.
    • Every participant sees the whole conversation. That’s what makes iterative refinement work, but it also means every speaker’s LLM bill scales with the round count. For longer chats, override UpdateHistoryAsync (.NET) or filter in the selector’s view to scope context.
    • Don’t share a manager instance across runs. .NET manager factories (Func<IReadOnlyList<AIAgent>, GroupChatManager>) exist for exactly this reason: each run gets a fresh manager with IterationCount = 0. If you cache and reuse a manager, Reset() is your friend; the factory pattern is safer.
    • AgentResponseUpdateEvent is per-token, not per-turn. If you only want one line per speaker, buffer by ExecutorId and flush on change (see FlushTurn in Program.cs). .NET also does not fire AgentResponseEvent for group chat, so filter on the update event instead.
    • Azure OpenAI concurrency limits still apply. Three agents sharing one deployment will serialize on TPM/RPM quotas. Group chat already runs speakers serially (one per round), so this is less acute than in Ch13, but a prompt-driven manager adds a fourth call per round, so budget accordingly.
    • Python’s orchestrator_agent and selection_func are mutually exclusive. Pass one or the other, not both. The builder raises at construction time if you try.

    Tests

    Python: 1 wiring test + 3 real-LLM integration tests.

    Terminal window
    source agents/python/.venv/bin/activate
    python -m pytest tutorials/15-group-chat-orchestration/python/tests/ -v
    # 4 passed

    The integration tests cover:

    • Round-robin order: writer must speak before critic before editor.
    • Non-empty content: every speaker produces at least some text.
    • Editor ≠ writer output: guards against a broken selector that cycles the same agent or trivially echoes the draft.

    .NET: build + end-to-end run against real credentials. No xUnit suite per chapter; the capstone’s integration harness (agents/dotnet/tests/ECommerceAgents.*.Tests/) is where orchestrated workflows get unit-test coverage in the refactor phase.

    Terminal window
    cd tutorials/15-group-chat-orchestration/dotnet
    dotnet build
    dotnet run -- "slogan for a coffee shop" # round-robin
    dotnet run -- "slogan for a coffee shop" prompt # prompt-driven

    When Group Chat is the right shape

    Reach for Group Chat when:

    • Iterative refinement beats one-shot generation. Copy review, proposal critique, code review cycles.
    • The next speaker depends on what just happened. Not a fixed order (Sequential), not independent parallel takes (Concurrent), not an agent handing off (Handoff).
    • Everyone needs the full context. Each speaker sees the whole conversation, the default behaviour.
    • You want observable manager decisions. Prompt-driven and agent-driven managers leave a trace of why each speaker was picked. Round-robin is the auditable-deterministic floor.

    Prefer Sequential (Ch12) when the order is fixed. Prefer Concurrent (Ch13) when agents don’t need to see each other’s output. Prefer Handoff (Ch14) when the current agent decides who’s next. Prefer Magentic (Ch16) when the whole plan (including which agents to even involve) is LLM-generated.

    How this shows up in the capstone

    Group Chat is a candidate for a future “product launch review” workflow where product-discovery, pricing-promotions, and a compliance specialist iterate on launch copy. Not in the initial Phase 7 refactor: the plan deliberately picks Sequential and Concurrent first because they have direct pre-refactor state-machine analogues in agents/python/workflows/. Group Chat is flagged as a follow-up in the master plan once the marketplace team adds the compliance agent.

    Further reading

    This chapter

    Microsoft Agent Framework docs

    • Workflows: Group Chat Orchestration (C#)4
    • Workflows: Group Chat Orchestration (Python)5

    Series shared resources

    What’s next

    Chapter 16: Magentic Orchestration is the other LLM-driven orchestration shape. The Magentic manager maintains a running facts ledger (what it has learned) and a plan (what’s left to do), delegates to workers, observes results, and iterates until satisfied. Group Chat’s manager picks the next speaker from a fixed roster; Magentic’s manager picks which worker to even involve, and when to replan. Same loop shape as this chapter, but a different, richer state.

    References

    1. tutorials/15-group-chat-orchestration — github.com 2

    2. python/main.py — github.com

    3. dotnet/Program.cs — github.com

    4. Workflows: Group Chat Orchestration (C#) — learn.microsoft.com

    5. Workflows: Group Chat Orchestration (Python) — learn.microsoft.com

    Comments

    Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.