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
.envwithOPENAI_API_KEYor the Azure OpenAI trio (AZURE_OPENAI_ENDPOINT,AZURE_OPENAI_KEY,AZURE_OPENAI_DEPLOYMENT). This chapter makes real LLM calls. uvfor 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.
(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.
| Strategy | Decision source | Knobs |
|---|---|---|
| Round-robin | A pure function over current_round and participant order. No LLM call. | max_rounds (Python) / MaximumIterationCount (.NET). Optional termination callback. |
| Prompt-driven | A small LLM call inside the manager: “given this transcript, who should speak next?” | Same cap knobs, plus the selector prompt itself. |
| Agent-driven | A 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.
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 inagent_framework.orchestrations. Acceptsparticipants=, one ofselection_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.GroupChatStateexposescurrent_round,participants(ordered name → description), andconversation(fulllist[Message]so far).orchestrator_agent— a Python alternative toselection_func: hand an entireAgentto 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. OverrideSelectNextAgentAsync(pick the nextAIAgent), optionallyShouldTerminateAsync(stop the loop), andUpdateHistoryAsync(filter the history each agent sees). ExposesIterationCountandMaximumIterationCount.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 subclassGroupChatManageryourself (shown below).max_rounds/MaximumIterationCount— the safety net. If the selector never terminates, the loop ends after this many speaker turns with aGroupChatOrchestrator 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: overrideShouldTerminateAsync.
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:
GroupChatStategives you everything you need.state.participantsis anOrderedDict[str, str]keyed by agent name;state.current_roundis a zero-based index;state.conversationis the fulllist[Message]so far. No hidden state, nothing async required.max_rounds=3is 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 atermination_conditioncallback for early termination.- No decorators, no executor wiring. Compare to Chapter 09’s manual fan-out graph —
GroupChatBuildergenerates 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, notOpenAI.Chat.ChatClient.IChatClientis 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 theChatCompletionOptionsABI drift betweenMicrosoft.Extensions.AI.OpenAIversions). Get one viachatClient.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. ShouldTerminateAsyncis the graceful exit. When it returnstrue, the workflow emits the final conversation and stops.MaximumIterationCountis 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#
| Aspect | Python | .NET |
|---|---|---|
| Builder entry point | GroupChatBuilder(participants=..., selection_func=...) | AgentWorkflowBuilder.CreateGroupChatBuilderWith(managerFactory).AddParticipants(...).Build() |
| Round-robin strategy | selection_func using state.current_round | RoundRobinGroupChatManager(agents) |
| Prompt-driven strategy | orchestrator_agent=<Agent> (no custom code) | Subclass GroupChatManager, call IChatClient in SelectNextAgentAsync |
| Agent-driven strategy | Same orchestrator_agent= — an Agent with tools/instructions | Same: a GroupChatManager subclass can hold any state, including an AIAgent |
| Max rounds | max_rounds=N on the builder | MaximumIterationCount = N on the manager instance |
| Termination condition | termination_condition=lambda messages: ... | Override ShouldTerminateAsync |
| Per-turn event | executor_completed with list[AgentExecutorResponse] | AgentResponseUpdateEvent (streaming) + WorkflowOutputEvent final |
| Kicking off the run | workflow.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/MaximumIterationCountor 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
nameattribute; .NET comparesAIAgent.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 withIterationCount = 0. If you cache and reuse a manager,Reset()is your friend; the factory pattern is safer. AgentResponseUpdateEventis per-token, not per-turn. If you only want one line per speaker, buffer byExecutorIdand flush on change (seeFlushTurninProgram.cs). .NET also does not fireAgentResponseEventfor 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_agentandselection_funcare 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 passedThe 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-drivenWhen 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
- Source on GitHub: tutorials/15-group-chat-orchestration
- Previous: Chapter 14 — Handoff Orchestration · Next: Chapter 16 — Magentic Orchestration
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.

