Skip to main content

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

Nitin Kumar Singh
Author
Nitin Kumar Singh
I build enterprise AI solutions and cloud-native systems. I write about architecture patterns, AI agents, Azure, and modern development practices — with full source code.
MAF v1 — Group Chat Orchestration (Python + .NET)
MAF v1: Python and .NET - This article is part of a series.
Part 15: This Article

Series note — Part of MAF v1: Python and .NET. Fourth orchestration pattern after Sequential, Concurrent, and Handoff. Next: Magentic.

Repo — Full runnable code: tutorials/15-group-chat-orchestration.

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.

Prerequisites
#

  • Completed Chapter 14 — Handoff Orchestration.
  • Repo-root .env with OPENAI_API_KEY or the Azure OpenAI trio (AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY, AZURE_OPENAI_DEPLOYMENT). This chapter makes real LLM calls.
  • uv for Python; .NET 10 SDK for the .NET sample.

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.

%%{init: {'theme':'base', 'themeVariables': { 'primaryColor': '#2563eb','primaryTextColor': '#ffffff','primaryBorderColor': '#1e40af', 'lineColor': '#64748b','secondaryColor': '#f59e0b','tertiaryColor': '#10b981', 'background': 'transparent'}}}%% stateDiagram-v2 direction LR [*] --> Observe Observe: Observe state
(full conversation + round index) Select: Select next speaker
(round-robin / prompt / agent) Run: Run selected agent
append response Check: Check termination
(max_rounds or condition) Done: Emit final conversation Observe --> Select Select --> Run Run --> Check Check --> Observe: continue Check --> Done: terminate Done --> [*] classDef core fill:#2563eb,stroke:#1e40af,color:#ffffff classDef success fill:#10b981,stroke:#047857,color:#ffffff class Observe,Select,Run,Check core class Done success

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 — traces show up in Aspire the same as any other agent.

%%{init: {'theme':'base', 'themeVariables': { 'primaryColor': '#2563eb','primaryTextColor': '#ffffff','primaryBorderColor': '#1e40af', 'lineColor': '#64748b','secondaryColor': '#f59e0b','tertiaryColor': '#10b981', 'background': 'transparent'}}}%% flowchart LR classDef core fill:#2563eb,stroke:#1e40af,color:#ffffff classDef external fill:#f59e0b,stroke:#b45309,color:#000000 classDef success fill:#10b981,stroke:#047857,color:#ffffff classDef infra fill:#64748b,stroke:#334155,color:#ffffff state([Manager state
round index + conversation]) subgraph RR["Round-robin"] direction TB rrFn[[selection_func
or
RoundRobinGroupChatManager]] rrOut([next speaker
participants by round index]) rrFn --> rrOut end subgraph PD["Prompt-driven"] direction TB pdFn[[SelectNextAgentAsync
inside GroupChatManager]] pdLlm[(LLM)] pdOut([next speaker
parsed from JSON]) pdFn -- "roster + transcript" --> pdLlm pdLlm -- "JSON: next=critic" --> pdFn pdFn --> pdOut end subgraph AD["Agent-driven"] direction TB adAgent[orchestrator Agent
instructions + tools] adOut([next speaker
agent picks from roster]) adAgent --> adOut end state --> rrFn state --> pdFn state --> adAgent class rrFn,pdFn,adAgent core class pdLlm external class state infra class rrOut,pdOut,adOut success

Same loop, same state input, three different things plugged into the selection slot. The loop doesn’t know which strategy is in use — 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 — 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.py. 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()
    )

Three things 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
#

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.cs. 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 — 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)));
}

Three 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
#

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
#

  • The manager can loop forever. Set max_rounds / MaximumIterationCount or ensure your termination condition eventually fires. Python emits a visible log line when the cap trips: GroupChatOrchestrator reached max_rounds=N; forcing completion. .NET terminates silently. Both defaults (no cap in .NET, 40 iterations historically) are wrong for demos — always set a small explicit cap.
  • 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 — 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 — 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.

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.

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

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 — different, richer state.

MAF v1: Python and .NET - This article is part of a series.
Part 15: This Article

Related