Multi-Agent Orchestration in Microsoft Agent Framework in C#
A single AI agent is powerful. But a single agent trying to simultaneously research a topic, critique its own work with fresh eyes, and synthesize everything into a polished report? That's asking for mediocrity. Multi-agent orchestration in the Microsoft Agent Framework solves this by distributing cognitive work across specialized agents -- each focused on one role, each contributing to a quality outcome the others couldn't achieve alone.
This article walks through a real multi-agent research application that demonstrates multi-agent orchestration in Microsoft Agent Framework (Microsoft.Agents.AI v1.0.0-rc1). You'll see how three agents -- a researcher, a critic, and a writer -- coordinate through a revision loop that keeps improving the output until quality gates are met. You'll also see two distinct approaches to managing agent context: the raw IChatClient pattern with explicit message lists, and MAF's AgentSession for framework-managed conversation state.
Why Multi-Agent Systems?
The case for multiple agents rests on three ideas.
Specialization through system prompts. An LLM responds differently to "you are an expert researcher focused on completeness and accuracy" versus "you are a critical reviewer focused on identifying gaps and weaknesses." Same underlying model, radically different behavior. Separate agents with separate system prompts let you exploit this consistently.
Separation of concerns. A researcher accumulates breadth. A critic challenges depth. A writer synthesizes for readability. These are genuinely different cognitive modes. Combining them in a single agent and a single prompt creates conflicting incentives -- the same agent that wrote something is unlikely to ruthlessly critique it.
Quality loops. When a critic can flag gaps and a researcher can revise in response, the system self-improves. A single-pass agent just produces output. A multi-agent loop with feedback can iterate toward a quality bar.
For comparison, Semantic Kernel's multi-agent orchestration uses AgentGroupChat with selection strategies to manage agent turns. MAF's approach -- and the pattern in this app -- is explicit coordination code you write yourself. More control, more transparency.
The Multi-Agent Research App
The multi-agent research application coordinates three agents to produce a research report on any topic:
- ResearchAgent -- gathers comprehensive, structured information on the topic
- CriticAgent -- reviews the research, rates quality, and identifies specific gaps
- WriterAgent -- transforms approved research into a polished, developer-friendly report
The ResearchOrchestrator class in Orchestration/ResearchOrchestrator.cs owns the coordination logic. It takes all three agents via constructor injection and runs them in sequence, with a feedback loop between the researcher and critic:
public class ResearchOrchestrator
{
private readonly ResearchAgent _researcher;
private readonly CriticAgent _critic;
private readonly WriterAgent _writer;
private readonly int _maxRevisions;
public ResearchOrchestrator(
ResearchAgent researcher,
CriticAgent critic,
WriterAgent writer,
int maxRevisions = 2)
{
_researcher = researcher;
_critic = critic;
_writer = writer;
_maxRevisions = maxRevisions;
}
}
The maxRevisions parameter caps how many revision cycles are allowed. This prevents runaway loops in cases where the critic keeps finding issues.
Agent Specialization via System Prompts
Each agent in this app is specialized through its system prompt, passed at construction time. Here's how the ResearchAgent sets itself up in Agents/ResearchAgent.cs:
public class ResearchAgent
{
private readonly IChatClient _chatClient;
private readonly ChatOptions? _options;
public ResearchAgent(IChatClient chatClient, ChatOptions? options = null)
{
_chatClient = chatClient;
_options = options;
}
public async Task<string> ResearchAsync(string topic, CancellationToken cancellationToken = default)
{
var messages = new List<ChatMessage>
{
new(ChatRole.System,
"You are an expert researcher. Gather comprehensive, accurate information on topics. " +
"Include facts, use cases, best practices, and practical examples. " +
"Provide detailed, well-structured research that covers multiple perspectives."),
new(ChatRole.User,
$"Research the following topic thoroughly: {topic}. " +
"Cover key concepts, benefits, use cases, and practical examples.")
};
var response = await _chatClient.GetResponseAsync(messages, _options, cancellationToken);
return response.Text ?? string.Empty;
}
}
Notice this agent uses IChatClient.GetResponseAsync directly -- not AsAIAgent(). This is a deliberate design choice in the multi-agent research app, and understanding why reveals something important about when to use each approach.
Two Approaches: IChatClient vs AsAIAgent()
The content pipeline app (covered in MAF Workflows in C#) uses chatClient.AsAIAgent(instructions: "...") to create ChatClientAgent instances. Each agent is a single-turn call: send prompt, get response, done.
The multi-agent research app takes a different approach: IChatClient.GetResponseAsync with explicit List<ChatMessage>. Here's why this matters:
IChatClient.GetResponseAsync gives you direct control over the full message list. You construct the System message yourself, you construct the User message yourself, and you can include any prior messages you want. The research app uses this to pass context explicitly -- the researcher's previous output and the critic's feedback appear directly in the next call's message list.
AsAIAgent() is cleaner for simpler agents. When an agent's behavior is fully defined by its system prompt and each call is independent, AsAIAgent() is less code and handles the message structuring for you. The CriticAgent in a pipeline where it only ever reviews one draft is a good candidate for this.
Neither approach is universally better. The multi-agent research app's use of raw IChatClient is a conscious trade-off: more verbose, more explicit, easier to debug when the context needs to be precise.
The Revision Loop Pattern
The heart of the multi-agent research app is in ResearchOrchestrator.RunAsync:
public async Task<ResearchResult> RunAsync(string topic, CancellationToken cancellationToken = default)
{
// Initial research pass
Console.WriteLine("[Researcher] Gathering information...");
var currentResearch = await _researcher.ResearchAsync(topic, cancellationToken);
int revisionCycles = 0;
for (int cycle = 0; cycle < _maxRevisions; cycle++)
{
// Critic reviews current research
Console.WriteLine("[Critic] Reviewing research quality...");
var critique = await _critic.CritiqueAsync(currentResearch, cancellationToken);
// Quality gate: does the critique indicate problems?
bool needsRevision =
critique.Contains("gap", StringComparison.OrdinalIgnoreCase) ||
critique.Contains("gaps", StringComparison.OrdinalIgnoreCase) ||
critique.Contains("missing", StringComparison.OrdinalIgnoreCase) ||
critique.Contains("weak", StringComparison.OrdinalIgnoreCase) ||
critique.Contains("incomplete", StringComparison.OrdinalIgnoreCase) ||
critique.Contains("lacking", StringComparison.OrdinalIgnoreCase);
if (!needsRevision)
{
Console.WriteLine("Research quality approved!");
break;
}
revisionCycles++;
Console.WriteLine($"Gaps found. Researcher revising (cycle {cycle + 1}/{_maxRevisions})...");
// Researcher revises based on critique
currentResearch = await _researcher.ReviseAsync(
currentResearch,
critique,
cancellationToken);
}
// Writer produces final report from approved research
Console.WriteLine("[Writer] Crafting final report...");
var finalReport = await _writer.WriteReportAsync(topic, currentResearch, cancellationToken);
return new ResearchResult { /* ... */ };
}
The loop is deliberately capped at _maxRevisions (default: 2). Without this cap, a critic that always finds something to criticize would run forever. The cap trades off quality ceiling for runtime predictability.
Quality Gates: How the Critic Signals Problems
The critic's job in Agents/CriticAgent.cs is to review research and identify gaps. Its system prompt tells it to "rate research quality on a 1-10 scale" and "list specific improvements needed":
public async Task<string> CritiqueAsync(string research, CancellationToken cancellationToken = default)
{
var messages = new List<ChatMessage>
{
new(ChatRole.System,
"You are a critical reviewer. Identify gaps, inaccuracies, and weaknesses in research. " +
"Be specific and actionable. Rate research quality on a 1-10 scale. " +
"Focus on: completeness, accuracy, practical examples, clarity, and depth."),
new(ChatRole.User,
$"Review this research and identify gaps or weaknesses. " +
$"Rate quality 1-10 and list specific improvements needed:\n\n{research}")
};
var response = await _chatClient.GetResponseAsync(messages, _options, cancellationToken);
return response.Text ?? string.Empty;
}
The orchestrator's quality gate is simple string inspection on the critique text. If the critique contains words like "gap", "missing", "weak", "incomplete", or "lacking", the research needs another revision cycle. If not, the loop exits early.
This is a pragmatic approach. It's not foolproof -- a critique that uses synonyms like "insufficient" would slip through -- but it works well in practice because LLM critiques naturally use these signal words when identifying problems. If you need a stricter gate, you could prompt the critic to return structured output (e.g., a JSON object with a needsRevision: true/false field) and parse that instead.
Context Passing Between Agents
In the multi-agent research app, context flows between agents as plain strings. The researcher's output becomes the critic's input. The critic's output becomes the researcher's revision prompt. This is explicit and transparent -- you always know exactly what each agent is seeing.
The ReviseAsync method on ResearchAgent shows how the revision context is structured:
public async Task<string> ReviseAsync(
string originalResearch,
string critique,
CancellationToken cancellationToken = default)
{
var messages = new List<ChatMessage>
{
new(ChatRole.System,
"You are an expert researcher. Gather comprehensive, accurate information on topics. " +
"When given critique, address all points raised and improve your research."),
new(ChatRole.User,
$"Based on this critique, improve your research:\n\n" +
$"Critique:\n{critique}\n\n" +
$"Your previous research:\n{originalResearch}")
};
var response = await _chatClient.GetResponseAsync(messages, _options, cancellationToken);
return response.Text ?? string.Empty;
}
Both the critique and the original research appear in the user message. The system prompt has been updated to include revision-awareness. The model sees everything it needs to improve the output.
This explicit string-passing approach is stateless -- each call to GetResponseAsync is independent. The "memory" lives in the orchestrator's currentResearch variable, not inside the agent.
AgentSession for Framework-Managed Context
MAF also provides AgentSession for scenarios where you want the framework to maintain conversation history across multiple turns with the same agent -- rather than managing it yourself.
With AsAIAgent(), you can create a session and pass it across calls:
// Create a session -- framework manages message history
AgentSession session = await agent.CreateSessionAsync();
// First turn -- initial research
AgentResponse r1 = await agent.RunAsync("Research the topic: dependency injection", session);
// Second turn -- researcher remembers the first turn
AgentResponse r2 = await agent.RunAsync(
$"Improve your research based on this critique: {critique}",
session);
The key difference from the string-passing approach: with AgentSession, the framework accumulates messages internally. The second call knows about the first call without you explicitly including the previous response in the prompt. This is similar to how session and context management works in the GitHub Copilot SDK for C# -- the session is the container for conversation history.
Use AgentSession when:
- You're building multi-turn conversations with a single agent
- You want the agent to accumulate context across many calls
- You want MAF to handle message history bookkeeping
Use explicit string-passing (the IChatClient.GetResponseAsync approach) when:
- Each agent call is essentially stateless
- You need precise control over exactly what context each agent sees
- You're passing context between different agents (not continuing a conversation with one)
The multi-agent research app's pattern -- passing research and critique as strings -- is better suited to inter-agent communication. The agents aren't having a conversation; they're performing independent tasks on shared data.
When Multi-Agent Makes Sense
Adding agents has a cost: each LLM call takes time and uses tokens. A single agent with a well-crafted prompt can often do surprisingly well on tasks that seem like they need multiple agents. So when does the multi-agent pattern pay off?
Multi-agent is worth it when:
- Tasks genuinely require different perspectives (writing vs. critiquing)
- You need a quality loop that iterates toward a threshold
- Different phases of work require fundamentally different "personalities" or expertise areas
- Parallelism can offset the latency of multiple sequential calls
Single agent with tools is worth it when:
- The task is fundamentally one cognitive operation
- You need real-time tool use (database queries, web search, API calls) during execution
- Context window limits aren't a concern
- Simplicity and debuggability matter more than quality ceilings
The ChatCompletionAgent vs AssistantAgent comparison in Semantic Kernel explores similar trade-offs from SK's perspective -- the same thinking applies when deciding whether to add a second MAF agent or enrich a single one with more tools.
Coordination Patterns in Practice
The multi-agent research app demonstrates the feedback loop pattern: researcher → critic → (revise if needed) → writer. But there are other coordination patterns worth having in your toolkit:
Sequential handoff: Each agent receives the previous agent's full output and produces its own. The content pipeline app uses this: writer → [parallel reviews] → editor. Clean data flow, no shared state.
Consensus loop: Multiple agents independently assess a piece of work and their assessments are compared or aggregated before proceeding. Useful when accuracy is critical and you want to cross-validate outputs.
Escalation chain: An agent tries to complete a task. If it can't or produces low-confidence output, a more capable (or differently specialized) agent takes over. Useful when you have agents of varying capability or cost.
Fan-out and gather: One orchestrator dispatches work to multiple specialized agents in parallel, then collects and synthesizes results. This is essentially what the content pipeline's parallel review step does at a small scale.
Putting It Together
Multi-agent orchestration in the Microsoft Agent Framework is less about the framework and more about your coordination logic. MAF gives you well-defined agent primitives (ChatClientAgent, IAIAgent, AgentSession). The orchestration -- who calls whom, in what order, with what context -- is yours to write.
The multi-agent research app shows multi-agent orchestration in Microsoft Agent Framework concretely: three agents, each with a distinct role and system prompt. An orchestrator that manages a feedback loop with configurable depth. A quality gate built from simple string inspection. Context passed as plain strings between agents. The whole thing runs in a console app with around 200 lines of C# across the key classes.
The pattern scales. Add more reviewer agents and run them in parallel before the writer. Add a different writer agent for different output formats. Add a fact-checking step after the writer. The coordination code stays clear because each agent's responsibility stays narrow.
If you're building research tooling, content workflows, or analysis pipelines where quality matters, the researcher-critic-writer pattern is a strong starting point. It's explicit, observable, and gives you natural leverage points to improve output quality by tuning individual agent prompts without touching the orchestration logic.
Frequently Asked Questions
What is multi-agent orchestration in the Microsoft Agent Framework in C#?
Multi-agent orchestration in the Microsoft Agent Framework means coordinating multiple specialized ChatClientAgent or IChatClient-based agents in a structured workflow. Each agent has a distinct system prompt and role. An orchestrator class -- like ResearchOrchestrator -- decides which agent runs when and how results flow between them.
How does multi-agent orchestration in Microsoft Agent Framework differ from a single agent?
A single agent uses one system prompt for all tasks. Multi-agent orchestration in Microsoft Agent Framework assigns each cognitive role to a separate agent -- researcher, critic, writer. This specialization produces higher quality outputs because each agent is entirely focused on one job, and a feedback loop between critic and researcher allows iterative improvement.
When should I use AgentSession vs explicit message passing in MAF?
Use AgentSession when you're building multi-turn conversations with a single agent and want MAF to manage the message history automatically. Use explicit List<ChatMessage> with IChatClient.GetResponseAsync when you're passing context between different agents -- where each call is essentially stateless and you need precise control over what each agent sees.
How do you prevent infinite loops in a revision-based multi-agent system?
Cap the revision loop with a _maxRevisions counter, as the ResearchOrchestrator does. Set the maximum based on your quality/latency trade-off tolerance. Also design your quality gate to exit early when no issues are found -- the orchestrator's break when needsRevision is false ensures good research doesn't burn unnecessary revision cycles.
Can different agents share the same IChatClient in MAF multi-agent orchestration?
Yes. In the multi-agent research app, a single IChatClient is passed to ResearchAgent, CriticAgent, and WriterAgent at startup. Each agent constructs its own message list per call, so there's no shared state risk. Sharing one IChatClient is both safe and efficient -- it keeps provider configuration centralized.

