Planner, then reviewer: two agents in one workflow
Summarize with AI
The assistants open a new chat that asks for a short summary of this post.
The idea: when a step has to happen every time, put the order in code instead of leaving it to the model.
An agent with tools decides for itself what to do next. Some steps have to happen every time, though. A household’s dinner plan should always be checked against its rules before anyone sees it, not only when the model thinks of calling a checking tool. A workflow puts that order in code. Here, a planner writes three dinners and a reviewer checks them, and every plan goes through the review.
Who this is for: C# developers who have built a first agent, as in Microsoft Agent Framework: your first C# agent, and given it tools, as in Tool calling for AI agents in C#.
Versions: Microsoft Agent Framework 1.24.0, .NET 10.
Code: samples/agent-framework/05-first-workflow, a runnable version of this post with its tests.
Note
The code runs on Claude Haiku 5.5, but it depends only on IChatClient: the first post shows the swap to OpenAI or Ollama.
The output limit of 4,096 tokens is sized for Haiku, which counts its reasoning as output.
When a workflow beats one agent with tools
A single agent with a check_rules tool can skip the check, call it with the wrong plan, or stop after the first fix, because the model decides the order. In a workflow, an edge sends every plan to the review. Each agent also carries only the instructions for its own job, so the reviewer can be swapped or tested without touching the planner.
The cost is a second agent run that reads the request and the plan again. A workflow fits when the steps are known in advance and must all happen. An agent with tools fits when the next step depends on what the model finds.
Add the workflow package
A console app, Anthropic’s Claude Haiku 5.5 as the model, and the workflow package, which is stable in 1.24:
dotnet new console -n FirstWorkflowcd FirstWorkflowdotnet add package Microsoft.Agents.AI --version 1.24.0dotnet add package Microsoft.Agents.AI.Anthropic --version 1.24.0dotnet add package Microsoft.Agents.AI.Workflows --version 1.24.0The chat client is the only statement that depends on Anthropic. AsIChatClient takes the default model ID and the default maximum number of output tokens:
using Anthropic;using Anthropic.Core;using Microsoft.Agents.AI;using Microsoft.Agents.AI.Workflows;using Microsoft.Extensions.AI;
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");
IChatClient chatClient = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsIChatClient("claude-haiku-5-5", 4096);The reviewer thinks before it answers, and Claude Haiku 5.5 counts those reasoning tokens as output tokens. With a limit of 1,024 the reviewer often ran out: its list of changes stopped mid-sentence, and once it printed nothing at all. 4,096 leaves room for the reasoning and the answer.
Two agents with one job each
The planner proposes, the reviewer checks. Each gets instructions for its own job and nothing else:
AIAgent planner = chatClient.AsAIAgent( name: "planner", instructions: """ You plan dinners for one household. Plan three dinners for the request. One line per dinner: the day, the dish and its main ingredients. Nothing else. """);
AIAgent reviewer = chatClient.AsAIAgent( name: "reviewer", instructions: """ You check a dinner plan against the household's rules: nobody eats peanuts, fish at most once a week, and every weekday dinner takes under 40 minutes. Return the plan in the same format with every broken rule fixed, then one line per change saying which rule it fixed. """);Both are ordinary agents. Either could be run on its own with RunAsync. The planner always plans three dinners, because its instructions say so, which fits the request below. What makes them a pipeline is the graph.
Connect them with an edge
A workflow is made of steps and the connections between them. A step is called an executor. An edge is a connection: it sends one step’s output to the next step. This workflow has two steps:
A WorkflowBuilder starts from the step that receives the input. AddEdge connects the planner to the reviewer, WithOutputFrom names the steps whose output the workflow reports, and Build checks the graph and returns a Workflow:
Workflow workflow = new WorkflowBuilder(planner) .AddEdge(planner, reviewer) .WithOutputFrom(planner, reviewer) .Build();An agent can go straight into the builder. The framework wraps each agent in an executor for you.
Start the run with two statements
Starting the workflow takes two statements. The first gives the workflow its input. InProcessExecution.RunStreamingAsync starts it in the current process and returns a StreamingRun. A plain string works as input; the planner receives it as a user message:
await using StreamingRun run = await InProcessExecution.RunStreamingAsync( workflow, "Dinners for Monday, Wednesday and Saturday. We have salmon.");The second statement tells the agents to start answering. Leave it out and the run ends quietly, with no output and no error, as shown below:
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));Despite the name, a turn token has nothing to do with the tokens a model counts. It is a start signal. An agent placed in a workflow collects the messages it receives and calls its model only when a turn token arrives, so one step can receive several messages and answer them all in one model call. The framework describes the turn token as a request to answer the messages collected so far; it works like a Send button.
Agents placed in the graph directly wait for it, and so does every other ChatProtocolExecutor, such as the final step that BuildSequential adds below. A step you write yourself, which calls an agent with RunAsync, runs as soon as its message arrives.
The planner sends the next turn token itself. When it finishes, it passes on the request it got, then its answer, then a new turn token, so the reviewer has the request and the plan before its start signal arrives.
emitEvents: true makes each agent stream its answer and raise an event for every piece of text. Without it, the agents still run, but without streaming and without an event that carries their text, so this program prints only “Workflow finished.”
Print the events as they stream
WatchStreamAsync yields the run’s events as they happen, and the stream ends when the workflow has nothing left to do:
string? lastExecutor = null;HashSet<string> cutOff = [];await foreach (WorkflowEvent evt in run.WatchStreamAsync()){ switch (evt) { case AgentResponseUpdateEvent update: if (update.ExecutorId != lastExecutor) { lastExecutor = update.ExecutorId; Console.WriteLine($"\n--- {update.ExecutorId}"); } Console.Write(update.Update.Text); if (update.Update.FinishReason == ChatFinishReason.Length && cutOff.Add(update.ExecutorId)) { Console.Error.WriteLine( $"\n{update.ExecutorId} stopped at the output token limit."); } break; case ExecutorFailedEvent failed: Console.Error.WriteLine( $"\n{failed.ExecutorId} failed: {failed.Data}"); break; case WorkflowErrorEvent error: Console.Error.WriteLine( $"\nWorkflow error: {error.Exception}"); break; }}
Console.WriteLine("\nWorkflow finished.");AgentResponseUpdateEvent carries one piece of streamed text in Update, and ExecutorId says which step produced it. The program prints a header line whenever that ID changes, so the planner’s text and the reviewer’s text are easy to tell apart.
AgentResponseUpdateEvent is a kind of WorkflowOutputEvent, the event that carries whatever a workflow reports as output. In 1.24 the WithOutputFrom line changes nothing about what this program prints: an agent’s streamed text comes out as an event whether or not its step is named. That is a legacy shortcut, and the framework’s Futures class says the stricter behavior becomes the default in 2.0.0. From then on, an agent’s text only leaves the workflow when its step is named in WithOutputFrom (or WithIntermediateOutputFrom), and without that line this program would print only “Workflow finished.” Setting Futures.EnableAgentResponseOutputTaggingAndFiltering = true at startup turns the new behavior on today.
ExecutorFailedEvent reports an exception inside one step, and WorkflowErrorEvent reports a failure of the whole run. Without cases for these two, a failed run looks like a successful one: it prints whatever arrived before the failure, then “Workflow finished.” A failing agent raises both. “Workflow finished.” comes after the loop, because the stream ends when the workflow has nothing left to do.
A cut-off answer is not a failure either. An agent that reaches the output limit stops mid-sentence, the workflow finishes as usual, and nothing marks the answer as incomplete. The last updates of that agent carry ChatFinishReason.Length, so the loop checks for it and says so once on standard error, with the same comparison as in the first post, made on the streamed updates. The Anthropic client puts the finish reason on each of the last few updates, so cutOff remembers which agents it has already warned about. A complete run looks like this:
--- planner_13ef12db4c4849a099bab9bdb3873cb2Monday: Lemon-herb baked salmon with garlic, olive oil, lemon, dill, and roasted asparagusWednesday: Salmon teriyaki bowls with rice, soy sauce, mirin, ginger, scallions, and steamed broccoliSaturday: Salmon tacos with corn tortillas, cabbage slaw, avocado, lime, and chipotle crema--- reviewer_d0b4008df6dd446eb03a20578c191dfdMonday: Lemon-herb sheet-pan chicken breasts with garlic, olive oil, lemon, dill, and roasted asparagus (about 25 minutes)Wednesday: Teriyaki chicken bowls with rice, soy sauce, mirin, ginger, scallions, and steamed broccoli (about 30 minutes)Saturday: Salmon tacos with corn tortillas, cabbage slaw, avocado, lime, and chipotle crema- Monday: Swapped salmon for chicken so the plan has only one fish dinner, which meets the fish-once-a-week limit.- Wednesday: Swapped salmon for chicken so the plan has only one fish dinner, which meets the fish-once-a-week limit.- Wednesday: Chicken teriyaki bowls take about 30 minutes, under the 40-minute weekday limit.- Monday: Sheet-pan chicken with asparagus takes about 25 minutes, under the 40-minute weekday limit.Workflow finished.In this run the planner put salmon in all three dinners, and the reviewer kept it only on Saturday. It also added cooking times, and two lines that confirm a rule instead of naming a fix, which the instructions didn’t ask for: the reviewer is a model too. The executor IDs are the agent’s name, an underscore and the agent’s Id, a GUID unless you set one.
The reviewer sees more than the plan. It gets the household’s request and then the planner’s answer. The planner’s answer arrives as a user message, so the reviewer reads it as input to check, not as something it said itself. Two AIAgentHostOptions settings, both on by default, do this: ForwardIncomingMessages makes the planner pass on the request before its answer, and ReassignOtherAgentsAsUsers turns another agent’s message into a user message.
A workflow without a turn token prints nothing
Leave out the TrySendMessageAsync line and the program still runs and exits normally. This is the complete program from this post with only that line removed:
Workflow finished.Nothing from the agents and no error: each agent is still waiting for its start signal. When a workflow prints nothing, check this line first.
The same chain in one call
A straight line of agents has a builder of its own, which replaces the WorkflowBuilder code:
Workflow workflow = AgentWorkflowBuilder.BuildSequential(planner, reviewer);It wires the same edges and adds a final executor that yields the whole conversation as the workflow’s output, a List<ChatMessage> delivered in a WorkflowOutputEvent. The run starts the same way, turn token included.
AgentWorkflowBuilder has builders for other shapes too. BuildConcurrent gives the same input to several agents at once and collects their answers, CreateHandoffBuilderWith lets an agent pass the conversation to another with a tool call, CreateGroupChatBuilderWith lets a manager pick which agent speaks next, and CreateMagenticBuilderWith puts a manager agent in charge of a team.
The limits of a fixed chain
The reviewer is itself a model and can miss a rule. The workflow guarantees that the review runs, not that it is right. A rule that must always hold belongs in code, as a plain C# check in its own step.
Every request in this workflow takes the same path. A question like “what can I cook tonight?” goes through the planner and the reviewer just the same, and sending it somewhere else needs edge conditions in the graph, not a longer chain.
The Workflow object has a limit of its own: with the default environment behind InProcessExecution.RunStreamingAsync, it can only be run by one runner at a time, so starting a second run before the first StreamingRun has been disposed throws Cannot use a Workflow that is already owned by another runner or parent workflow. InProcessExecution.Concurrent.RunStreamingAsync allows several runs at once, as long as every step in the workflow supports it, which agents placed in the graph do.
Try it
- Tell the reviewer to reject any plan with fish, ask the planner for fish, and watch the review come back in the output.
- Add a third agent, for example one that writes the shopping list, connect it with one more
AddEdge, and add it toWithOutputFrom.